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:
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user