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

View File

@@ -641,7 +641,7 @@ All use the standard error envelope (`data.error.code`).
| HTTP | Code | Meaning | Agent action |
|---|---|---|---|
| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment |
| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment; a `scriptbody` / `apppackage` download retries once (shared key, or a credential enrolled meanwhile) and the command result carries `detail.authfallback`; with no shared key the command fails saying so |
| 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential |
| 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
@@ -674,8 +674,16 @@ for the SYSTEM reporter scripts.
### 9.5 Server pins
- `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views)
merged with `serverpins` (string array) in appsettings.json. Legacy
`servercertthumbprint` is still honoured.
is authoritative whenever the value EXISTS in either view, even empty: its
pins (possibly none) are the whole set and `serverpins` / legacy
`servercertthumbprint` in appsettings.json are ignored. Only when the value
is absent in both views do the appsettings.json pins apply. The agent logs
which source is in effect (`registry` or `appsettings.json`) at startup and
on every change.
- `Set-ShopDBServerPins.ps1 -Pins <pins>` writes the set; `-None` writes an
empty value (registry authoritative, no pins, normal chain validation);
`-Clear` deletes the value (appsettings.json pins apply again). To retire an
old pin after a rotation, write only the new pin.
- A pin is the lowercase hex SHA-256 of the server leaf certificate's
SubjectPublicKeyInfo. The Server Certificate settings page shows it
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);

View File

@@ -150,17 +150,42 @@ def _leaf_certificate_facts(pembytes):
return _spki_sha256(block), isca
def _current_server_pin():
"""Pin of this server's own leaf certificate, or None when not found/CA."""
def _current_server_facts():
"""(spkisha256, isca) of this server's own leaf certificate, else (None, None)."""
path = _server_cert_path()
if not path:
return None
return None, None
try:
with open(path, 'rb') as handle:
pin, isca = _leaf_certificate_facts(handle.read())
return _leaf_certificate_facts(handle.read())
except OSError:
return None
return None if isca else pin
return None, None
def _current_server_pin():
"""Pin of this server's own leaf certificate, or None when not found.
The SPKI pin does not depend on basicConstraints, so a CA-flagged
self-signed certificate is pinned like any other leaf.
"""
return _current_server_facts()[0]
# Shown to the admin at build time (response header and UI) when the pinned
# certificate carries basicConstraints CA:TRUE.
CA_CERT_BUILD_WARNING = (
'The server certificate has basicConstraints CA:TRUE, so install.ps1 will '
'not import it into LocalMachine\\Root. The package still pins it: agents '
'1.20 and newer trust the server by pin alone. Agents older than 1.20 and '
'PowerShell 5.1 downloads without pin support will not trust this server. '
'Regenerate the server certificate in Settings > Endpoints > Server '
'certificate (the generator does not set CA), or trust a non-CA '
'certificate on those PCs with Install-ShopDBServerCertificate.ps1.')
CA_CERT_BOTH_PINS_WARNING = (
'The uploaded CA-flagged certificate does not match this server\'s own '
'certificate, so both pins were included in ServerPins: the uploaded '
'certificate and the server\'s own certificate.')
CA_WARNING_HEADER = 'X-ShopDB-Build-Warning'
# The non-secret runtime config shipped in the package. baseurl and apikey stay
@@ -485,6 +510,12 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
}}
if ($certIsCa) {{
Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root."
if ($pinList.Count -gt 0) {{
Write-Warning "ServerPins were written: agents 1.20 and newer trust the server by pin alone."
}}
Write-Warning "Agents older than 1.20, and PowerShell 5.1 downloads without pin support, will NOT trust this server."
Write-Warning "Fix: regenerate the server certificate with the ShopDB generator (Settings > Endpoints > Server certificate; it does not set CA),"
Write-Warning "or have an admin trust a non-CA server certificate on this PC with Install-ShopDBServerCertificate.ps1."
}}
else {{
Write-Host " trusting bundled server certificate $CertFileName ..."
@@ -889,7 +920,9 @@ def package_siteagent():
Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when
present), a non-secret appsettings.json, install.ps1, and README.txt. The
collector key is embedded only in install.ps1 as its default parameter and
is never stored or logged.
is never stored or logged. When the pinned certificate is CA-flagged the
response carries an X-ShopDB-Build-Warning header explaining that only
pinning (agents 1.20 and newer) will trust the server.
"""
if request.form or request.files:
source = request.form
@@ -959,29 +992,42 @@ def package_siteagent():
certfilename = 'server.crt' if (cert_pem or certpath) else None
# Pin the bundled leaf when there is one; a CA bundle cannot be pinned (the
# agent hashes the leaf) so fall back to this server's own certificate.
# Pin the leaf the server presents: the bundled/uploaded leaf, else this
# server's own certificate. The SPKI pin ignores basicConstraints, so a
# CA-flagged self-signed cert is still pinned; the CA flag only gates the
# Root import.
trustcertinroot = True
currentpin = None
pinisca = False
if certfilename:
if cert_pem:
bundledbytes = cert_pem.encode('ascii')
else:
with open(certpath, 'rb') as handle:
bundledbytes = handle.read()
bundledpin, bundledisca = _leaf_certificate_facts(bundledbytes)
currentpin, bundledisca = _leaf_certificate_facts(bundledbytes)
trustcertinroot = not bundledisca
if not bundledisca:
currentpin = bundledpin
pinisca = bool(bundledisca)
# An uploaded CA-flagged cert may be the server's own self-signed cert or an
# internal CA root sent by mistake. Ship the server's own pin beside it so
# the agent (which accepts any listed pin) works either way.
serverownpin = None
if cert_pem and pinisca:
serverownpin = _current_server_pin()
if serverownpin == currentpin:
serverownpin = None
if currentpin is None:
currentpin = _current_server_pin()
currentpin, pinisca = _current_server_facts()
warnings = [CA_CERT_BUILD_WARNING] if (currentpin and pinisca) else []
if serverownpin:
warnings.append(CA_CERT_BOTH_PINS_WARNING)
if nextserverpin and currentpin is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'nextserverpin needs a current server pin; bundle or upload the '
'server leaf certificate',
http_code=400)
serverpins = [pin for pin in (currentpin, nextserverpin) if pin]
serverpins = [pin for pin in (currentpin, serverownpin, nextserverpin) if pin]
serverpins = list(dict.fromkeys(serverpins))
appsettings['serverpins'] = serverpins
@@ -1020,8 +1066,11 @@ def package_siteagent():
agentversion = agentstore.current_version('agent')
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
if agentversion else 'shopdb-endpoint-agent.zip')
return send_file(
response = send_file(
buffer,
mimetype='application/zip',
as_attachment=True,
download_name=download_name)
if warnings:
response.headers[CA_WARNING_HEADER] = ' '.join(warnings)
return response

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

View File

@@ -103,6 +103,18 @@ The script compares before it writes, so `Always` costs nothing per cycle.
A running agent picks up a changed `ServerPins` within about 60 seconds; no
service restart is needed.
**The registry wins.** The installer and the store upgrade also write
`serverpins` into appsettings.json. When `ServerPins` exists in the registry
(either view), even empty, the agent uses only the registry and ignores the
appsettings.json pins; only a PC with no `ServerPins` value falls back to them.
The agent log says which source is in effect (`Server certificate trust from
registry` / `from appsettings.json`, and `Server pins changed, source ...`).
- `-Pins <pin>[,<pin>]`: write exactly this set.
- `-None`: write an EMPTY value. No pins, normal Windows certificate checks,
appsettings.json pins ignored.
- `-Clear`: DELETE the value. The appsettings.json pins apply again (if any).
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ)
equal to the pin on the settings page, and the next `report-asset-*.log` under
@@ -113,7 +125,10 @@ RESPONSE line.
certificate a new key (a regenerated or re-issued certificate) changes the pin.
Add the NEXT pin everywhere first: put the old AND the new pin in `-Pins`
(`"<oldpin>,<newpin>"`), wait until every PC has both, only then switch the
certificate on the server, then drop the old pin. Switching the server first
certificate on the server, then retire the old pin by writing ONLY the new pin
to the registry (`-Pins <newpin>`). Do not use `-Clear` for this: deleting the
value brings back whatever pins appsettings.json carries, which may be the old
one. Switching the server first
cuts off every pinned PC, including its ability to receive the fixed pin list
through anything that talks to ShopDB. A renewal that keeps the same key keeps
the same pin. PCs trusted through the Root store (below) also need the new
@@ -312,7 +327,8 @@ enrolled PC. Block re-enrollment FIRST.
4. Verify: the Fleet row reports the old agent version, and the reporter logs
show `RESPONSE (shared key)`.
5. Remove the PC from the pin entry's targets only if pinning itself is the
problem (`Set-ShopDBServerPins.ps1 -Clear` removes the value).
problem. `Set-ShopDBServerPins.ps1 -None` turns pinning off outright;
`-Clear` deletes the value, which falls back to any appsettings.json pins.
6. Later, when moving the PC forward again, lift the block:
`POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` (or the
same action in the UI). The PC enrolls on its next attempt once it runs

View File

@@ -357,8 +357,9 @@
For a reverse-proxy or internal-CA server this app cannot see, upload
the server's public certificate in PEM format. It is validated,
bundled with the package, pinned, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code> (a CA certificate is
bundled but never imported or pinned). Leave empty when the
Trusted Root store by <code>install.ps1</code> (a CA-flagged
certificate is bundled and pinned but never imported, so only agents
1.20 and newer will trust the server). Leave empty when the
server uses a publicly trusted certificate. An upload takes precedence
over the bundled server certificate above.
</p>
@@ -376,6 +377,7 @@
</button>
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
<!-- ================================================================
@@ -603,6 +605,7 @@ const agentcertfile = ref(null)
const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
const agentwarning = ref('')
const agentincludesharedkey = ref(true)
const agentnextserverpin = ref('')
const agentserverpin = ref('')
@@ -835,6 +838,7 @@ async function buildSiteAgent() {
agentbusy.value = true
agentmessage.value = ''
agenterror.value = ''
agentwarning.value = ''
try {
const form = new FormData()
form.append('baseurl', agentbaseurl.value)
@@ -866,6 +870,7 @@ async function buildSiteAgent() {
link.remove()
window.URL.revokeObjectURL(url)
agentmessage.value = 'Site agent built.'
agentwarning.value = response.headers?.['x-shopdb-build-warning'] || ''
} catch (buildError) {
if (buildError.response?.status === 400 && buildError.response?.data) {
agenterror.value = await readBlobError(buildError.response.data)