Always pin the server, let the registry own pins, and survive a revoke mid-upgrade
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

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.
This commit is contained in:
cproudlock
2026-09-15 15:22:25 -04:00
parent 949398d495
commit f4a03387b2
10 changed files with 381 additions and 100 deletions

View File

@@ -19,17 +19,27 @@
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 drop the old pin. A PC holding
only the old pin refuses the new certificate.
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
Remove ServerPins from both views. Pinning is then off and the normal
Windows certificate checks apply.
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.
@@ -42,6 +52,14 @@
.\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
@@ -51,6 +69,7 @@
param(
[string]$Pins,
[switch]$Clear,
[switch]$None,
[string]$LogPath
)
@@ -69,16 +88,20 @@ function Log {
}
}
# 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 @() }
if (-not $key) { return $result }
try {
$value = $key.GetValue($ValueName)
if ($null -eq $value) { return @() }
return @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
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() }
}
@@ -97,9 +120,15 @@ function Test-SamePins {
# --- 1. validate the request ------------------------------------------------
$requested = @()
if (-not $Clear) {
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 (use -Clear to remove pinning)'
Log 'ERROR no -Pins given (-Clear deletes the value, -None writes it empty)'
exit 1
}
foreach ($raw in ($Pins -split '[,;\s]+')) {
@@ -128,10 +157,11 @@ $views = @(
$failed = $false
foreach ($entry in $views) {
try {
$current = @(Get-CurrentPins -View $entry.View)
$state = Get-CurrentPins -View $entry.View
$current = @($state.Pins)
if ($Clear) {
if ($current.Count -eq 0) {
if (-not $state.Present) {
Log "$($entry.Name): no ServerPins; nothing to do"
continue
}
@@ -143,12 +173,12 @@ foreach ($entry in $views) {
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
}
} finally { $base.Dispose() }
Log "$($entry.Name): removed ServerPins"
Log "$($entry.Name): deleted ServerPins (appsettings.json pins apply)"
}
continue
}
if (Test-SamePins -Left $current -Right $requested) {
if ($state.Present -and (Test-SamePins -Left $current -Right $requested)) {
Log "$($entry.Name): ServerPins already correct; nothing to do"
continue
}
@@ -164,8 +194,8 @@ foreach ($entry in $views) {
} finally { $base.Dispose() }
# Read back rather than trust SetValue.
$after = @(Get-CurrentPins -View $entry.View)
if (-not (Test-SamePins -Left $after -Right $requested)) {
$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