[M5] tools: Get-AsbPassphrase.ps1 — DPAPI loader for live-probe env

Reads the ASB solution shared secret from the local Windows registry
(HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\<solution>\
sharedsecret) and DPAPI-decrypts it with the canonical "wonderware"
entropy + LocalMachine scope, mirroring `AsbRegistry.cs:21-41`.

Auto-discovers:
  $env:MX_LIVE             = "1"
  $env:MX_ASB_HOST         = $env:COMPUTERNAME
  $env:MX_ASB_SOLUTION     = (read from DefaultASBSolution)
  $env:MX_ASB_GALAXY_NAME  = "ZB" (or -GalaxyName param)
  $env:MX_ASB_VIA          = net.tcp://<host>/ASBService/Default_<galaxy>_MxDataProvider/IDataV2
  $env:MX_ASB_PASSPHRASE   = (DPAPI-decrypted plaintext, never printed unless -Show)

Important wiring detail flagged inline: the system-wide ArchestrA
solution name (`Archestra_<HOST>`, source of the sharedsecret) is
DIFFERENT from the per-Galaxy MxDataProvider service segment
(`Default_<galaxy>_MxDataProvider`) that the WCF endpoint URL
targets. Both live under the same registry root but only the former
is owned by ArchestrA; the latter is what serves IASBIDataV2 per
the .NET probe's hardcoded default URL at
`src/MxAsbClient.Probe/Program.cs:5`.

Tested via dry-run on this box: `Archestra_DESKTOP-6JL3KKO` resolves
as the solution, 390 protected bytes decrypt to an 80-char
passphrase, and the assembled VIA URL matches the .NET probe's
default verbatim.

Hard rules:
* Plaintext passphrase NEVER printed unless -Show is explicit.
* Dot-source so env vars persist in the calling pwsh session.
* Caller account must be authorised against the LocalMachine-scope
  DPAPI blob (typically: any local Administrator).

Usage:
  . .\tools\Get-AsbPassphrase.ps1
  cargo run -p mxaccess --example asb-subscribe

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 14:45:43 -04:00
parent e3baeb8803
commit 4ebfd8e3a3
+145
View File
@@ -0,0 +1,145 @@
# 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-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"
$via = "net.tcp://$AsbHost/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
Write-Host ''
Write-Host 'Done. Run the example with:' -ForegroundColor Green
Write-Host ' cargo run -p mxaccess --example asb-subscribe' -ForegroundColor DarkGray