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

@@ -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