Files
shopdb-flask/plugins/endpoints/client/Set-ShopDBServerPins.ps1
cproudlock 8f10c09a76 Always pin the server, let the registry own pins, and survive a revoke mid-upgrade
Found in the Windows VM re-test.

- A CA-flagged server certificate (common with a plain openssl self-signed
  cert) produced an installer package with no pin at all, so agent 1.20
  refused the server. The package now always carries the SPKI pin of the
  certificate PCs receive; the CA flag only skips the trusted-root import.
  When an uploaded CA-flagged certificate differs from the server's own, both
  pins are included. The build response carries X-ShopDB-Build-Warning and the
  installer logs what older agents need.
- Pins written into appsettings.json by the installer were merged with the
  registry, so removing a pin from HKLM never took effect. When the ServerPins
  value exists in the registry it is now authoritative, even when empty;
  appsettings pins apply only when it is absent. Set-ShopDBServerPins.ps1
  -Clear deletes the value; new -None writes an empty value (no pins).
- A script or package download sent with a credential revoked moments earlier
  got 401 CREDENTIAL_INVALID and failed the command. The agent now retries that
  download once with the shared key and records the fallback in the result.
2026-09-15 16:07:06 -04:00

213 lines
7.8 KiB
PowerShell

#requires -Version 5.1
<#
.SYNOPSIS
Write the ShopDB server public key pins to HKLM:\SOFTWARE\GE\ShopDB
ServerPins, so the endpoint agent and the SYSTEM reporters trust only this
site's server key.
.DESCRIPTION
Each pin is the lowercase hex SHA-256 of the server leaf certificate's
SubjectPublicKeyInfo. The ShopDB Server Certificate settings page shows the
current one, and Get-ShopDBServerPin.ps1 computes one from a URL or a
certificate file.
Written as REG_MULTI_SZ in BOTH registry views (64-bit and WOW6432Node),
the same way CollectorKey is, so a 32-bit reader sees the same set.
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, before agent
1.20 reaches the PC. Idempotent: the set on the PC is compared with the
requested set first, and nothing is written when they already match, so it
is safe every cycle.
THE REGISTRY WINS. When ServerPins exists in either view, even empty, the
agent uses it alone and ignores serverpins / servercertthumbprint in
appsettings.json (the installer and store upgrade write those). Only when
the value is absent in both views do the appsettings pins apply.
ROTATING THE SERVER KEY: pass the old pin AND the new pin until every PC has
both, switch the server certificate, then write ONLY the new pin to retire
the old one. A PC holding only the old pin refuses the new certificate.
.PARAMETER Pins
One or more pins separated by commas, semicolons or spaces. Colons and case
are ignored. Every value must be 64 hex characters or nothing is written.
.PARAMETER Clear
Delete ServerPins from both views. The agent then falls back to the pins
in appsettings.json; with none there, normal Windows certificate checks.
.PARAMETER None
Write an EMPTY ServerPins in both views. The registry stays authoritative
with no pins, so appsettings.json pins are ignored and the normal Windows
certificate checks apply.
.PARAMETER LogPath
Append a transcript here as well as writing to the pipeline.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -Pins <pin>
Pin one server key.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf
Show what a key rotation would write without touching the registry.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -Pins <newpin>
After the server switched certificates: retire the old pin.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -None
Turn pinning off on a PC whose appsettings.json still carries pins.
.NOTES
Exit codes:
0 written, or already correct
1 bad input or an error stopped the write
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$Pins,
[switch]$Clear,
[switch]$None,
[string]$LogPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$SubKey = 'SOFTWARE\GE\ShopDB'
$ValueName = 'ServerPins'
function Log {
param([string]$Message)
$line = "{0} [Set-ShopDBServerPins] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Output $line
if ($LogPath) {
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
}
}
# Returns @{ Present; Pins }. Present = the value exists, even empty.
function Get-CurrentPins {
param([Microsoft.Win32.RegistryView]$View)
$result = @{ Present = $false; Pins = @() }
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
try {
$key = $base.OpenSubKey($SubKey)
if (-not $key) { return $result }
try {
$value = $key.GetValue($ValueName)
if ($null -eq $value) { return $result }
$result.Present = $true
$result.Pins = @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
return $result
} finally { $key.Dispose() }
} finally { $base.Dispose() }
}
function Test-SamePins {
param([string[]]$Left, [string[]]$Right)
$a = @($Left | Sort-Object -Unique)
$b = @($Right | Sort-Object -Unique)
if ($a.Count -ne $b.Count) { return $false }
for ($i = 0; $i -lt $a.Count; $i++) {
if ($a[$i] -ne $b[$i]) { return $false }
}
return $true
}
# --- 1. validate the request ------------------------------------------------
$requested = @()
if (@(@($Clear.IsPresent, $None.IsPresent, [bool]$Pins) | Where-Object { $_ }).Count -gt 1) {
Log 'ERROR use only one of -Pins, -Clear, -None'
exit 1
}
if ($None) {
Log 'requested an empty ServerPins (registry authoritative, no pins)'
} elseif (-not $Clear) {
if (-not $Pins -or -not $Pins.Trim()) {
Log 'ERROR no -Pins given (-Clear deletes the value, -None writes it empty)'
exit 1
}
foreach ($raw in ($Pins -split '[,;\s]+')) {
if (-not $raw) { continue }
$pin = ($raw -replace ':', '').ToLowerInvariant()
if ($pin -notmatch '^[0-9a-f]{64}$') {
Log "ERROR '$raw' is not a SHA-256 pin (64 hex characters); nothing written"
exit 1
}
if ($requested -notcontains $pin) { $requested += $pin }
}
if ($requested.Count -eq 0) {
Log 'ERROR -Pins held no values; nothing written'
exit 1
}
Log "requested $($requested.Count) pin(s): $($requested -join ', ')"
}
# --- 2. write each view -----------------------------------------------------
$views = @(
@{ Name = '64-bit'; View = [Microsoft.Win32.RegistryView]::Registry64 },
@{ Name = '32-bit'; View = [Microsoft.Win32.RegistryView]::Registry32 }
)
$failed = $false
foreach ($entry in $views) {
try {
$state = Get-CurrentPins -View $entry.View
$current = @($state.Pins)
if ($Clear) {
if (-not $state.Present) {
Log "$($entry.Name): no ServerPins; nothing to do"
continue
}
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", "Remove $ValueName")) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
try {
$key = $base.OpenSubKey($SubKey, $true)
if ($key) {
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
}
} finally { $base.Dispose() }
Log "$($entry.Name): deleted ServerPins (appsettings.json pins apply)"
}
continue
}
if ($state.Present -and (Test-SamePins -Left $current -Right $requested)) {
Log "$($entry.Name): ServerPins already correct; nothing to do"
continue
}
$action = "Set $ValueName to $($requested.Count) pin(s) (was $($current.Count))"
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", $action)) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
try {
$key = $base.CreateSubKey($SubKey)
try {
$key.SetValue($ValueName, [string[]]$requested, [Microsoft.Win32.RegistryValueKind]::MultiString)
} finally { $key.Dispose() }
} finally { $base.Dispose() }
# Read back rather than trust SetValue.
$after = Get-CurrentPins -View $entry.View
if (-not $after.Present -or -not (Test-SamePins -Left @($after.Pins) -Right $requested)) {
Log "ERROR $($entry.Name): ServerPins did not read back as written"
$failed = $true
continue
}
Log "$($entry.Name): wrote ServerPins ($($requested.Count) pin(s), was $($current.Count))"
}
} catch {
Log "ERROR $($entry.Name): $($_.Exception.Message)"
$failed = $true
}
}
if ($failed) { exit 1 }
exit 0