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

@@ -14,13 +14,15 @@ public sealed class AgentConfig
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
// Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no
// separators). Still honoured next to ServerPins; breaks on every cert
// renewal, so prefer an SPKI pin. See AgentHttpFactory.
// separators). Honoured with serverpins only while the registry ServerPins
// value is absent; breaks on every cert renewal, so prefer an SPKI pin.
// See AgentHttpFactory.
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
// Server public key pins: lowercase hex SHA-256 of the leaf certificate's
// SubjectPublicKeyInfo. Merged with HKLM\SOFTWARE\GE\ShopDB ServerPins
// (REG_MULTI_SZ). Any pin configured = the leaf key must match one; chain
// SubjectPublicKeyInfo. Fallback only: HKLM\SOFTWARE\GE\ShopDB ServerPins
// (REG_MULTI_SZ), when it exists in either view, replaces these entirely,
// even when empty. Any pin in effect = the leaf key must match one; chain
// and name errors are then ignored. None = normal Windows chain validation.
[JsonPropertyName("serverpins")] public List<string> ServerPins { get; set; } = new();

View File

@@ -172,43 +172,55 @@ public sealed record AgentServerError(string Code, string Message)
}
}
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo,
// merged from HKLM\SOFTWARE\GE\ShopDB ServerPins (both views) and appsettings
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin
// configured only a matching leaf is accepted and chain/name errors are ignored
// (self-signed servers on the air gap). Without pins: SslPolicyErrors.None.
// Registry pins re-read at most every RefreshInterval on the handshake path, so a
// GE-Enforce fix or a "next" rotation pin lands without a service restart.
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo.
// HKLM\SOFTWARE\GE\ShopDB ServerPins is AUTHORITATIVE whenever the value exists
// in either view, even empty (empty = no pins, Windows chain validation), so a
// removed or rotated-out pin really goes away. Only when the value is absent in
// both views do appsettings serverpins and the legacy SHA-1 servercertthumbprint
// apply. With any pin in effect only a matching leaf is accepted and chain/name
// errors are ignored (self-signed servers on the air gap). Without pins:
// SslPolicyErrors.None. Registry re-read at most every RefreshInterval on the
// handshake path, so a GE-Enforce fix or a rotation lands without a restart.
public sealed class ServerPinSet
{
public const string RegistryValue = "ServerPins";
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60);
private sealed record Snapshot(HashSet<string> SpkiPins, string RawRegistry)
// Present = the value exists in at least one view.
private sealed record RegistryRead(bool Present, List<string> Values)
{
public string Signature => string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal));
public string Raw => Present ? "registry:" + string.Join("|", Values) : "absent";
}
private sealed record Snapshot(HashSet<string> SpkiPins, string? LegacyThumbprint, bool FromRegistry, string RawRegistry)
{
public string Signature => (FromRegistry ? "registry:" : "appsettings:")
+ string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal))
+ (LegacyThumbprint is null ? "" : "+legacy");
public bool Pinned => SpkiPins.Count > 0 || LegacyThumbprint is not null;
public string Source => FromRegistry ? "registry" : "appsettings.json";
}
private readonly HashSet<string> configPins;
private readonly string? legacyThumbprint;
private readonly ILogger logger;
private readonly Func<bool, List<string>?> readRegistry;
private readonly Func<bool, RegistryRead?> readRegistry;
private readonly object refreshLock = new();
private volatile Snapshot current;
private long nextRefreshTicks;
private ServerPinSet(HashSet<string> configPins, string? legacyThumbprint, ILogger logger,
Func<bool, List<string>?> readRegistry)
Func<bool, RegistryRead?> readRegistry)
{
this.configPins = configPins;
this.legacyThumbprint = legacyThumbprint;
this.logger = logger;
this.readRegistry = readRegistry;
current = Build(readRegistry(false) ?? new List<string>());
current = Build(readRegistry(false) ?? new RegistryRead(false, new List<string>()));
nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds;
}
public bool IsPinned => Current().SpkiPins.Count > 0 || legacyThumbprint is not null;
public bool IsPinned => Current().Pinned;
public static ServerPinSet Load(AgentConfig config, ILogger logger)
{
@@ -221,11 +233,15 @@ public sealed class ServerPinSet
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh));
var count = set.current.SpkiPins.Count;
var snapshot = set.current;
logger.LogInformation(
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
count, thumbprint is null ? "" : " + legacy thumbprint",
count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
"Server certificate trust from {Source}: {Pins} SPKI pin(s){Legacy}; {Mode}.",
snapshot.Source, snapshot.SpkiPins.Count,
snapshot.LegacyThumbprint is null ? "" : " + legacy thumbprint",
snapshot.Pinned ? "pinned" : "Windows chain validation");
if (snapshot.FromRegistry && (pins.Count > 0 || thumbprint is not null))
logger.LogInformation(
"Registry {Value} is set; ignoring appsettings.json serverpins/servercertthumbprint.", RegistryValue);
return set;
}
@@ -238,33 +254,38 @@ public sealed class ServerPinSet
{
if (Environment.TickCount64 < nextRefreshTicks)
return current;
var raw = readRegistry(true);
var read = readRegistry(true);
Interlocked.Exchange(ref nextRefreshTicks,
Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds);
var previous = current;
if (raw is null || string.Join("|", raw) == previous.RawRegistry)
if (read is null || read.Raw == previous.RawRegistry)
return previous;
var next = Build(raw);
var next = Build(read);
current = next;
if (next.Signature != previous.Signature)
logger.LogInformation(
"Server pins changed: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.",
"Server pins changed, source {Source}: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.",
next.Source,
next.SpkiPins.Count,
string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])),
legacyThumbprint is null ? "" : " + legacy thumbprint",
next.SpkiPins.Count > 0 || legacyThumbprint is not null ? "pinned" : "Windows chain validation");
next.LegacyThumbprint is null ? "" : " + legacy thumbprint",
next.Pinned ? "pinned" : "Windows chain validation");
return next;
}
}
// Malformed-pin warnings only fire when the raw registry text changes.
private Snapshot Build(List<string> raw)
private Snapshot Build(RegistryRead read)
{
var pins = new HashSet<string>(configPins, StringComparer.Ordinal);
foreach (var pin in raw)
if (!read.Present)
return new Snapshot(new HashSet<string>(configPins, StringComparer.Ordinal),
legacyThumbprint, FromRegistry: false, read.Raw);
var pins = new HashSet<string>(StringComparer.Ordinal);
foreach (var pin in read.Values)
AddPin(pins, pin, "registry", logger);
return new Snapshot(pins, string.Join("|", raw));
return new Snapshot(pins, LegacyThumbprint: null, FromRegistry: true, read.Raw);
}
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
@@ -276,7 +297,7 @@ public sealed class ServerPinSet
}
var snapshot = Current();
if (snapshot.SpkiPins.Count == 0 && legacyThumbprint is null)
if (!snapshot.Pinned)
{
if (errors == SslPolicyErrors.None)
return true;
@@ -293,8 +314,8 @@ public sealed class ServerPinSet
var spki = SpkiSha256(leaf);
if (snapshot.SpkiPins.Contains(spki))
return true;
if (legacyThumbprint is not null
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
if (snapshot.LegacyThumbprint is not null
&& string.Equals(leaf.GetCertHashString(), snapshot.LegacyThumbprint, StringComparison.OrdinalIgnoreCase))
return true;
logger.LogError(
@@ -327,9 +348,10 @@ public sealed class ServerPinSet
}
// Startup keeps whatever views read. A refresh returns null on any failed view
// so a transient denial cannot silently drop pins (and the server with them).
private static List<string>? ReadRegistryPins(ILogger logger, bool refresh)
// so a transient denial cannot silently swap the pin source (and the server with it).
private static RegistryRead? ReadRegistryPins(ILogger logger, bool refresh)
{
var present = false;
var found = new List<string>();
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
@@ -337,7 +359,10 @@ public sealed class ServerPinSet
{
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
switch (key?.GetValue(RegistryValue))
var value = key?.GetValue(RegistryValue);
if (value is not null)
present = true;
switch (value)
{
case string[] many:
found.AddRange(many);
@@ -357,6 +382,6 @@ public sealed class ServerPinSet
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
}
}
return found;
return new RegistryRead(present, found);
}
}

View File

@@ -145,10 +145,16 @@ public sealed class CommandExecutor
// installer pinned by SHA-256 and runs it silently. Both download
// the body themselves with the collector credential and fail closed.
case "run_script":
{
var auth = new DownloadAuth();
return RequireTarget(action, target)
?? await RunScript(target, claim.Script, token);
?? auth.Stamp(await RunScript(target, claim.Script, auth, token));
}
case "update_app":
return await UpdateApp(claim.App, token);
{
var auth = new DownloadAuth();
return auth.Stamp(await UpdateApp(claim.App, auth, token));
}
// Fixed destructive action: reboot this endpoint immediately. Runs
// as the SYSTEM agent, so shutdown does not need a user session.
@@ -1158,7 +1164,7 @@ public sealed class CommandExecutor
// is to trigger something already installed on the endpoint, use
// scheduledtask_run instead: it is a much smaller grant.
private async Task<CommandResult> RunScript(
string name, ScriptDeliveryClaim? script, CancellationToken token)
string name, ScriptDeliveryClaim? script, DownloadAuth auth, CancellationToken token)
{
if (script is null || string.IsNullOrWhiteSpace(script.InstallPath))
{
@@ -1211,10 +1217,7 @@ public sealed class CommandExecutor
using var client = httpFactory.CreateClient(
AgentHttpChannel.Download, TimeSpan.FromMinutes(5));
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl);
using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
using var response = await SendDownloadAsync(client, scriptUrl, "run_script", auth, token);
if (!response.IsSuccessStatusCode)
{
return new CommandResult
@@ -1872,7 +1875,8 @@ public sealed class CommandExecutor
// credential, verifies the SHA-256 (when the server pinned one), then runs
// an MSI via msiexec /qn or an EXE with the version's SilentArgs. Anything
// missing or mismatched fails closed.
private async Task<CommandResult> UpdateApp(AppDeliveryClaim? app, CancellationToken token)
private async Task<CommandResult> UpdateApp(
AppDeliveryClaim? app, DownloadAuth auth, CancellationToken token)
{
if (app is null || string.IsNullOrWhiteSpace(app.InstallPath))
{
@@ -1910,10 +1914,7 @@ public sealed class CommandExecutor
using var client = httpFactory.CreateClient(
AgentHttpChannel.Download, TimeSpan.FromMinutes(10));
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl);
using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
using var response = await SendDownloadAsync(client, packageUrl, "update_app", auth, token);
if (!response.IsSuccessStatusCode)
{
return new CommandResult
@@ -2010,6 +2011,64 @@ public sealed class CommandExecutor
}
}
// What a download had to do about a revoked credential, stamped onto the
// command result (message suffix + detail.authfallback) however it ends.
private sealed class DownloadAuth
{
public string? Note { get; set; }
public CommandResult Stamp(CommandResult result)
{
if (Note is null)
return result;
result.Message = $"{result.Message} ({Note})";
result.Detail ??= new Dictionary<string, object>();
result.Detail["authfallback"] = Note;
return result;
}
}
// GET for run_script/update_app bodies. A command claimed before a revoke
// downloads with the revoked credential and gets 401 CREDENTIAL_INVALID; the
// auth handler has already deleted it and woken enrollment. Resend ONCE,
// which picks up the shared key (or a credential enrolled meanwhile).
private async Task<HttpResponseMessage> SendDownloadAsync(
HttpClient client, string url, string action, DownloadAuth auth, CancellationToken token)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await client.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, token);
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized
|| !request.Headers.Contains(AgentAuthHandler.CredentialHeader))
return response;
// Buffered by the auth handler on 401.
var body = await response.Content.ReadAsStringAsync(token);
if (AgentServerError.TryParse(body)?.Code != AgentAuthErrorCodes.CredentialInvalid)
return response;
var credentials = httpFactory.Credentials;
if (credentials.Current is null && credentials.SharedKey is null)
{
auth.Note = "endpoint credential was revoked and no shared key is configured to retry the download; "
+ "the PC must re-enroll";
logger.LogWarning("{Action} download refused the revoked endpoint credential; no shared key to retry with",
action);
return response;
}
response.Dispose();
using var retry = new HttpRequestMessage(HttpMethod.Get, url);
var second = await client.SendAsync(
retry, HttpCompletionOption.ResponseHeadersRead, token);
var via = retry.Headers.Contains(AgentAuthHandler.SharedKeyHeader)
? "the shared key" : "the newly enrolled credential";
auth.Note = $"endpoint credential was revoked; download retried once with {via}: HTTP {(int)second.StatusCode}";
logger.LogWarning("{Action} download refused the revoked endpoint credential; retried with {Via}: HTTP {Status}",
action, via, (int)second.StatusCode);
return second;
}
// MSI installs run through msiexec /i <pkg> /qn (any SilentArgs appended);
// an EXE runs directly with its version's SilentArgs (homegrown EXEs vary by
// developer, so the switches are per-version server data, not guessed here).

View File

@@ -274,8 +274,10 @@
fallback until the PC is enrolled, so the build also runs against an
older server (a 404 on enroll parks enrollment for 6 hours). A revoked
credential (CREDENTIAL_INVALID) is deleted and the PC re-enrolls without
a restart. Server trust moves to SPKI SHA-256 pins (ServerPins registry
value and serverpins in appsettings.json), checked by one AgentHttpFactory
a restart, and a script or package download refused that way retries
once. Server trust moves to SPKI SHA-256 pins (the ServerPins registry
value when it exists, even empty; otherwise serverpins in
appsettings.json), checked by one AgentHttpFactory
that now builds every HttpClient, including script and package downloads
that previously had their own unpinned handlers. The tray holds no
secret any more: it reaches the service over the local named pipe

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)

View File

@@ -168,24 +168,109 @@ def test_installer_never_removes_enrolled_credential(client, db, auth_headers, s
assert 'credential' not in line.lower(), line
def test_ca_certificate_is_not_imported_into_root(client, db, auth_headers, servercert):
def _upload(client, headers, pem, filename, **fields):
data = {'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
'cadenceseconds': '900', 'certfile': (io.BytesIO(pem), filename)}
data.update(fields)
return client.post(PACKAGE_URL, data=data,
content_type='multipart/form-data', headers=headers)
def test_ca_certificate_is_pinned_but_not_imported_into_root(
client, db, auth_headers):
capem, capin = _make_cert(isca=True)
response = client.post(
PACKAGE_URL,
data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
'cadenceseconds': '900',
'certfile': (io.BytesIO(capem), 'ca.crt')},
content_type='multipart/form-data', headers=auth_headers)
response = _upload(client, auth_headers, capem, 'ca.crt')
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert 'server.crt' in files
assert '$trustCertInRoot = $false' in script
assert 'is a CA certificate; not importing it' in script
# A CA cannot be the pinned leaf; the server's own leaf is pinned instead.
pins = json.loads(files['appsettings.json'])['serverpins']
assert pins == [servercert]
assert capin not in script
assert 'Install-ShopDBServerCertificate.ps1' in script
assert 'older than 1.20' in script
# The SPKI pin ignores basicConstraints: the uploaded leaf is pinned.
assert '[string[]] $ServerPins = @("{0}")'.format(capin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
assert response.headers.get(siteagent.CA_WARNING_HEADER) == \
siteagent.CA_CERT_BUILD_WARNING
def test_uploaded_ca_certificate_ships_both_pins_when_server_cert_differs(
client, db, auth_headers, servercert):
capem, capin = _make_cert(isca=True)
nextpin = 'ef' * 32
response = _upload(client, auth_headers, capem, 'ca.crt',
nextserverpin=nextpin)
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert '[string[]] $ServerPins = @("{0}", "{1}", "{2}")'.format(
capin, servercert, nextpin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [
capin, servercert, nextpin]
assert '$trustCertInRoot = $false' in script
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
assert siteagent.CA_CERT_BUILD_WARNING in warning
assert 'both pins were included' in warning
def test_uploaded_ca_certificate_matching_server_cert_ships_one_pin(
client, db, auth_headers, tmp_path, monkeypatch):
capem, capin = _make_cert(isca=True)
path = tmp_path / 'server.crt'
path.write_bytes(capem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
response = _upload(client, auth_headers, capem, 'server.crt')
files = _files(response)
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
assert 'both pins' not in response.headers.get(siteagent.CA_WARNING_HEADER)
@pytest.fixture
def caservercert(tmp_path, monkeypatch):
"""A CA-flagged self-signed cert staged as this server's own certificate,
as a plain openssl req -x509 produces."""
pem, pin = _make_cert(isca=True)
path = tmp_path / 'server.crt'
path.write_bytes(pem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
return pin
def test_ca_flagged_server_cert_bundled_is_still_pinned(
client, db, auth_headers, caservercert):
nextpin = 'cd' * 32
response = _build(client, auth_headers, collectorkey=SECRET,
bundlecert=True, nextserverpin=nextpin)
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert 'server.crt' in files
assert '[string[]] $ServerPins = @("{0}", "{1}")'.format(caservercert, nextpin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert, nextpin]
assert '$trustCertInRoot = $false' in script
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
assert warning and 'Install-ShopDBServerCertificate.ps1' in warning
warning.encode('ascii')
def test_ca_flagged_server_cert_not_bundled_is_still_pinned(
client, db, auth_headers, caservercert):
response = _build(client, auth_headers, collectorkey=SECRET)
files = _files(response)
assert 'server.crt' not in files
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert]
assert response.headers.get(siteagent.CA_WARNING_HEADER)
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
assert status['serverpin'] == caservercert
def test_leaf_certificate_build_has_no_warning(client, db, auth_headers, servercert):
response = _build(client, auth_headers, collectorkey=SECRET, bundlecert=True)
_files(response)
assert siteagent.CA_WARNING_HEADER not in response.headers
def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):