Make rollback, first upgrades and pin changes work as tested on Windows

Found in an end-to-end test on a Windows 11 VM against a self-signed server.

- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
  older agent pushed afterwards arrived on an enrolled PC and was refused.
  Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
  never issued a credential and keeps working on the shared key until
  POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
  block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
  certificate when downloading the upgrade, so a store wave failed on PCs that
  do not trust a self-signed server while the Migration tab said ready. A
  failed download now marks the PC blocked with "server certificate not
  trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
  SYSTEM) adds a non-CA server certificate to the trusted roots after checking
  its thumbprint; the server certificate page offers a .cer download and the
  SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
  planned rotation), can leave the shared key out so the PC waits for
  approval, never touches an existing credential, and skips the trusted-root
  import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
  connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
  otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
  verifying the pin against the live URL; agent contract updated.
This commit is contained in:
cproudlock
2026-09-15 13:37:37 -04:00
parent 8eee6f19d8
commit 70d5531b4d
20 changed files with 1713 additions and 151 deletions

View File

@@ -41,8 +41,11 @@ public sealed class AgentHttpFactory
// Caller owns and disposes the client (it disposes the handler chain).
public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null)
{
// Lifetime cap: pooled TLS sessions were validated against the pins of
// their handshake, so recycle them for a pin change to reach new ones.
var sockets = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (_, cert, _, errors)
@@ -57,11 +60,9 @@ public sealed class AgentHttpFactory
// server keep-alive, or a reused dead socket yields "unexpected
// EOF or 0 bytes from the transport stream" (1.6.2).
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1);
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
break;
case AgentHttpChannel.FleetAlert:
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90);
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
break;
}
@@ -176,40 +177,94 @@ public sealed record AgentServerError(string Code, string Message)
// 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.
public sealed class ServerPinSet
{
public const string RegistryValue = "ServerPins";
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60);
private readonly HashSet<string> spkiPins;
private readonly string? legacyThumbprint;
private readonly ILogger logger;
private ServerPinSet(HashSet<string> spkiPins, string? legacyThumbprint, ILogger logger)
private sealed record Snapshot(HashSet<string> SpkiPins, string RawRegistry)
{
this.spkiPins = spkiPins;
this.legacyThumbprint = legacyThumbprint;
this.logger = logger;
public string Signature => string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal));
}
public bool IsPinned => spkiPins.Count > 0 || legacyThumbprint is not null;
private readonly HashSet<string> configPins;
private readonly string? legacyThumbprint;
private readonly ILogger logger;
private readonly Func<bool, List<string>?> 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)
{
this.configPins = configPins;
this.legacyThumbprint = legacyThumbprint;
this.logger = logger;
this.readRegistry = readRegistry;
current = Build(readRegistry(false) ?? new List<string>());
nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds;
}
public bool IsPinned => Current().SpkiPins.Count > 0 || legacyThumbprint is not null;
public static ServerPinSet Load(AgentConfig config, ILogger logger)
{
var pins = new HashSet<string>(StringComparer.Ordinal);
foreach (var pin in config.ServerPins ?? new List<string>())
AddPin(pins, pin, "appsettings.json", logger);
foreach (var pin in ReadRegistryPins(logger))
AddPin(pins, pin, "registry", logger);
string? thumbprint = null;
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh));
var count = set.current.SpkiPins.Count;
logger.LogInformation(
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
pins.Count, thumbprint is null ? "" : " + legacy thumbprint",
pins.Count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
return new ServerPinSet(pins, thumbprint, logger);
count, thumbprint is null ? "" : " + legacy thumbprint",
count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
return set;
}
// Cheap tick compare per handshake; registry read only when the window lapsed.
private Snapshot Current()
{
if (Environment.TickCount64 < Interlocked.Read(ref nextRefreshTicks))
return current;
lock (refreshLock)
{
if (Environment.TickCount64 < nextRefreshTicks)
return current;
var raw = readRegistry(true);
Interlocked.Exchange(ref nextRefreshTicks,
Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds);
var previous = current;
if (raw is null || string.Join("|", raw) == previous.RawRegistry)
return previous;
var next = Build(raw);
current = next;
if (next.Signature != previous.Signature)
logger.LogInformation(
"Server pins changed: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.",
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");
return next;
}
}
// Malformed-pin warnings only fire when the raw registry text changes.
private Snapshot Build(List<string> raw)
{
var pins = new HashSet<string>(configPins, StringComparer.Ordinal);
foreach (var pin in raw)
AddPin(pins, pin, "registry", logger);
return new Snapshot(pins, string.Join("|", raw));
}
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
@@ -220,7 +275,8 @@ public sealed class ServerPinSet
return false;
}
if (!IsPinned)
var snapshot = Current();
if (snapshot.SpkiPins.Count == 0 && legacyThumbprint is null)
{
if (errors == SslPolicyErrors.None)
return true;
@@ -235,7 +291,7 @@ public sealed class ServerPinSet
try
{
var spki = SpkiSha256(leaf);
if (spkiPins.Contains(spki))
if (snapshot.SpkiPins.Contains(spki))
return true;
if (legacyThumbprint is not null
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
@@ -270,7 +326,9 @@ public sealed class ServerPinSet
pins.Add(pin);
}
private static IEnumerable<string> ReadRegistryPins(ILogger logger)
// 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)
{
var found = new List<string>();
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
@@ -291,6 +349,11 @@ public sealed class ServerPinSet
}
catch (Exception error)
{
if (refresh)
{
logger.LogDebug(error, "Registry re-read of {Value} in {View} view failed; keeping pins", RegistryValue, view);
return null;
}
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
}
}