From da4b224ac17651548f1e54d5d431e384ed9c4974 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 15 Sep 2026 13:37:37 -0400 Subject: [PATCH] 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//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. --- .../AgentHttpFactory.cs | 101 ++++-- .../computers/client/Report-AssetToShopDB.ps1 | 30 +- plugins/endpoints/AGENT-CONTRACT.md | 33 +- plugins/endpoints/api/agentauth.py | 16 +- plugins/endpoints/api/enrollment.py | 176 +++++++++-- plugins/endpoints/api/routes.py | 54 ++++ plugins/endpoints/api/siteagent.py | 290 ++++++++++++++++-- .../api/templates/upgrade-shopdbagent.ps1 | 30 +- .../Install-ShopDBServerCertificate.ps1 | 194 ++++++++++++ .../docs/PER-PC-CREDENTIALS-ROLLOUT.md | 107 ++++++- .../frontend/views/AgentKeySettings.vue | 78 ++++- .../frontend/views/EnrollmentSettings.vue | 155 ++++++++-- .../frontend/views/enrollmentSettings.spec.js | 42 ++- .../versions/0023_endpoint_credentials.py | 18 +- plugins/endpoints/models/credential.py | 10 +- .../client/Report-PrintersToShopDB.ps1 | 30 +- .../test_endpoints_credentials.py | 148 ++++++++- .../test_endpoints_credentials_migration.py | 3 +- .../test_endpoints_enrollment_migration.py | 137 +++++++++ .../test_endpoints_siteagent_build.py | 212 +++++++++++++ 20 files changed, 1713 insertions(+), 151 deletions(-) create mode 100644 plugins/endpoints/client/Install-ShopDBServerCertificate.ps1 diff --git a/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs b/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs index c19dca3e..e0ddd1d2 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs @@ -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 spkiPins; - private readonly string? legacyThumbprint; - private readonly ILogger logger; - - private ServerPinSet(HashSet spkiPins, string? legacyThumbprint, ILogger logger) + private sealed record Snapshot(HashSet 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 configPins; + private readonly string? legacyThumbprint; + private readonly ILogger logger; + private readonly Func?> readRegistry; + private readonly object refreshLock = new(); + private volatile Snapshot current; + private long nextRefreshTicks; + + private ServerPinSet(HashSet configPins, string? legacyThumbprint, ILogger logger, + Func?> readRegistry) + { + this.configPins = configPins; + this.legacyThumbprint = legacyThumbprint; + this.logger = logger; + this.readRegistry = readRegistry; + current = Build(readRegistry(false) ?? new List()); + 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(StringComparer.Ordinal); foreach (var pin in config.ServerPins ?? new List()) 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 raw) + { + var pins = new HashSet(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 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? ReadRegistryPins(ILogger logger, bool refresh) { var found = new List(); 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); } } diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 index e26c1fa8..aa0c86d8 100644 --- a/plugins/computers/client/Report-AssetToShopDB.ps1 +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -133,6 +133,11 @@ public static class ShopDbServerPinValidator return Pins.Contains(LastPresented); } + public static bool IsPinned(string pin) + { + return !String.IsNullOrEmpty(pin) && Pins.Contains(pin); + } + // Walks the DER: Certificate > TBSCertificate > [0] version (optional), // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo. public static string SpkiSha256Hex(byte[] der) @@ -271,8 +276,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) { try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {} } $presented = '' - if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented } - return @{ Status = $status; Code = $code; PresentedPin = $presented } + $matched = $false + if ('ShopDbServerPinValidator' -as [type]) { + $presented = [string][ShopDbServerPinValidator]::LastPresented + $matched = [ShopDbServerPinValidator]::IsPinned($presented) + } + # Innermost exception text: the outer WebException hides the real cause + # (unexpected EOF, connection reset, trust failure). + $message = '' + $exception = $ErrorRecord.Exception + while ($exception) { + if ($exception.Message) { $message = $exception.Message } + $exception = $exception.InnerException + } + return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message } } function Write-ShopDbAuthFailure($Failure) { @@ -292,7 +309,14 @@ function Write-ShopDbAuthFailure($Failure) { } default { if ($Failure.Status -eq 0 -and $Failure.PresentedPin) { - Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins" + # A pin was seen, but only an absent pin is a pin failure. A + # matched pin means the handshake passed and something later + # failed (server dropped mid-transfer, timeout). + if ($Failure.PinMatched) { + Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)" + } else { + Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins" + } } } } diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md index 0bbdcd1b..3885699f 100644 --- a/plugins/endpoints/AGENT-CONTRACT.md +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -626,8 +626,10 @@ key authenticated the call, the hostname matches exactly one computer, the serial on record matches (trimmed, case-insensitive) and the asset has no active credential. Otherwise the request is pending with one of the reasons `unknown hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`, -`already enrolled`, `awaiting approval`, `nonce changed`. The credential is in -the 201 body exactly once. +`already enrolled`, `awaiting approval`, `nonce changed`, `reenroll blocked`. +`reenroll blocked` means an admin revoked the PC with `blockreenroll` (9.7); the +agent keeps retrying on its normal schedule and enrolls once an admin calls +`allow-reenroll`. The credential is in the 201 body exactly once. ### 9.3 Error codes @@ -646,7 +648,9 @@ as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`, `POST /api/endpoints/operatorreports`, `GET /api/endpoints/alerts`, `POST /api/endpoints/alerts//acknowledge`. With a credential, a missing hostname is allowed and the asset comes from the credential; an acknowledge with -assetid 0 or absent means the caller. The core collector +assetid 0 or absent means the caller. Acknowledging an alert that does not +target the caller returns 404 `NOT_FOUND`, as for an unknown id. +The core collector (`/api/collector/computers`, `/api/collector/printers`) accepts the same header for the SYSTEM reporter scripts. @@ -673,6 +677,14 @@ for the SYSTEM reporter scripts. (`spkisha256` on `GET /api/endpoints/settings/server-cert`); `client/Get-ShopDBServerPin.ps1` computes it from a URL or a file; `client/Set-ShopDBServerPins.ps1` writes it. +- The agent re-reads the pin set within about 60 seconds of a change; no + service restart is needed. +- The same `GET /api/endpoints/settings/server-cert` carries `sha1thumbprint` + (hex SHA-1 of the certificate), and + `GET /api/endpoints/settings/server-cert/download` returns the public + certificate as a `.cer`. `client/Install-ShopDBServerCertificate.ps1` checks + the file against that thumbprint and adds it to `LocalMachine\Root` on PCs + whose pre-1.20 agent validates the chain when it downloads the upgrade. - Any pin configured: accept the handshake if and only if the leaf key hash is in the set (chain and name errors ignored). No pin: require a clean Windows validation. @@ -702,7 +714,20 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited. - `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`) - `POST /api/endpoints/enrollment/requests//approve` `{"assetid": n}` - `POST /api/endpoints/enrollment/requests//reject` -- `POST /api/endpoints/enrollment/assets//revoke` (un-enroll) +- `POST /api/endpoints/enrollment/assets//revoke` (un-enroll). Optional + body `{"blockreenroll": true, "reason": "rollback"}` (`reason` <= 64 chars, + used only with `blockreenroll`) also keeps the PC from enrolling again: its + enroll requests stay pending with `reenroll blocked`, and the Migration tab + shows it blocked with the same blocker. Works on a PC whose credential is + already revoked. Without the block a 1.20 agent re-enrolls within about a + second of a plain revoke. +- `POST /api/endpoints/enrollment/assets//allow-reenroll` lifts the + block; the PC enrolls on its next attempt. 404 when the PC has no credential + record; a PC that was not blocked returns success unchanged. - `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}` - `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`) - `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100) +- Migration blockers include `server certificate not trusted by this PC`: the + PC's last upgrade download failed on the certificate chain (the server only + knows from the failure the PC reported). Trust the certificate + (`Install-ShopDBServerCertificate.ps1`) and queue it again. diff --git a/plugins/endpoints/api/agentauth.py b/plugins/endpoints/api/agentauth.py index 863e3c7e..d8147964 100644 --- a/plugins/endpoints/api/agentauth.py +++ b/plugins/endpoints/api/agentauth.py @@ -9,7 +9,9 @@ and, when the route names a PC, binds the caller through bind_hostname(). X-API-Key The shared fleet key (or a collector-scoped managed token). Accepted only while plugin.endpoints.sharedkeyallowed is on, and never for a - PC that holds an active credential. + PC that holds an active credential. A PC blocked + from re-enrolling has no active credential, so an + older agent on it keeps working on the shared key. Only the sha256 of a credential is stored. The full secret exists once, in the enroll response. @@ -79,6 +81,18 @@ def active_credential_for(assetid): .first()) +def reenroll_blocked(assetid): + """True when an admin blocked this asset from enrolling again.""" + from ..models import EndpointCredential + + if assetid is None: + return False + return (EndpointCredential.query + .filter(EndpointCredential.assetid == assetid, + EndpointCredential.reenrollblocked.is_(True)) + .first()) is not None + + def resolve_credential(secret, touch=True): """Asset id for an active credential, else None.""" from ..models import EndpointCredential diff --git a/plugins/endpoints/api/enrollment.py b/plugins/endpoints/api/enrollment.py index 3e557212..d5737cbc 100644 --- a/plugins/endpoints/api/enrollment.py +++ b/plugins/endpoints/api/enrollment.py @@ -7,7 +7,8 @@ Agent-facing: Admin (endpoints.fleet to read, admin role to change): GET /enrollment credential state per computer + requests POST /enrollment/requests//approve | reject - POST /enrollment/assets//revoke + POST /enrollment/assets//revoke body {"blockreenroll", "reason"} + POST /enrollment/assets//allow-reenroll lift a re-enrollment block PUT /enrollment/settings GET /enrollment/migration readiness of every computer for agent 1.20 POST /enrollment/migration/wave push the agent upgrade to a batch of PCs @@ -58,6 +59,7 @@ REASON_ALREADY_ENROLLED = 'already enrolled' REASON_AWAITING_APPROVAL = 'awaiting approval' REASON_NONCE_CHANGED = 'nonce changed' REASON_REJECTED = 'rejected' +REASON_REENROLL_BLOCKED = 'reenroll blocked' MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked', 'notreporting') @@ -71,6 +73,18 @@ BLOCKER_NO_PACKAGE = 'no agent package in store' BLOCKER_TOO_OLD = 'agent too old for remote upgrade' BLOCKER_POLICY = 'run_script not permitted by policy' BLOCKER_AWAITING_ENROLLMENT = 'awaiting enrollment' +BLOCKER_REENROLL_BLOCKED = 'reenroll blocked' +BLOCKER_CERT_UNTRUSTED = 'server certificate not trusted by this PC' + +# How agents up to 1.19 word a download refused over the server certificate. +# Only a PC's own recorded failure shows it; the server cannot read a trust store. +_TLS_TRUST_FAILURES = ( + 'ssl connection could not be established', + 'untrustedroot', + 'remotecertificatechainerrors', + 'the remote certificate is invalid', + 'could not establish trust relationship', +) _OFFLINE_AFTER = timedelta(hours=24) _WAVE_MAXIMUM = 100 @@ -224,6 +238,8 @@ def enroll_endpoint(): 'installation', http_code=403) if agentauth.active_credential_for(row.matchedassetid) is not None: return _pending(row, REASON_ALREADY_ENROLLED, row.matchedassetid) + if agentauth.reenroll_blocked(row.matchedassetid): + return _pending(row, REASON_REENROLL_BLOCKED, row.matchedassetid) if row.matchedassetid is None or db.session.get(Asset, row.matchedassetid) is None: return _pending(row, REASON_UNKNOWN_HOSTNAME) return _issue(row, row.matchedassetid, @@ -274,6 +290,8 @@ def enroll_endpoint(): return _pending(row, REASON_SERIAL_MISMATCH, assetid) if agentauth.active_credential_for(assetid) is not None: return _pending(row, REASON_ALREADY_ENROLLED, assetid) + if agentauth.reenroll_blocked(assetid): + return _pending(row, REASON_REENROLL_BLOCKED, assetid) if sharedkeyauthenticated and agentauth.auto_enroll_enabled(): return _issue(row, assetid, EndpointCredential.ENROLLED_AUTO) return _pending(row, REASON_AWAITING_APPROVAL, assetid) @@ -347,8 +365,12 @@ def get_enrollment(): rows = [] for assetid, hostname, serialnumber in _computers(): credential = credentials.get(assetid) + blocked = bool(credential is not None and credential.reenrollblocked) if credential is not None and credential.revokedat is None: state = 'enrolled' + elif blocked: + # Its agent keeps asking; those requests are expected, not pending work. + state = 'revoked' elif _is_pending(assetid, hostname, index): state = 'pending' elif credential is not None: @@ -364,6 +386,9 @@ def get_enrollment(): 'issuedat': _utciso(credential.issuedat) if credential else None, 'lastusedat': _utciso(credential.lastusedat) if credential else None, 'enrolledvia': credential.enrolledvia if credential else None, + 'reenrollblocked': blocked, + 'reenrollblockedreason': (credential.reenrollblockedreason + if blocked else None), }) counts = Counter(row['state'] for row in rows) @@ -430,6 +455,10 @@ def approve_enroll_request(requestid): return error_response(ErrorCodes.CONFLICT, 'That computer is already enrolled; revoke its ' 'credential first', http_code=409) + if agentauth.reenroll_blocked(assetid): + return error_response(ErrorCodes.CONFLICT, + 'That computer is blocked from enrolling again; ' + 'allow re-enrollment first', http_code=409) row.status = EndpointEnrollRequest.STATUS_APPROVED row.matchedassetid = assetid @@ -467,23 +496,83 @@ def reject_enroll_request(requestid): @endpoints_bp.route('/enrollment/assets//revoke', methods=['POST']) @require_role('admin') def revoke_endpoint_credential(assetid): - """Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.""" + """Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again. + + Body (optional) {"blockreenroll": true, "reason": "rollback"} also keeps the + PC from enrolling again until POST .../allow-reenroll. That is the step + before rolling a PC back to an agent that only knows the shared key: without + it the current agent re-enrolls within seconds and the older agent is then + refused. A PC whose credential is already revoked can still be blocked. + """ + payload = request.get_json(silent=True) or {} + if not isinstance(payload, dict): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Body must be a JSON object') + blockreenroll = payload.get('blockreenroll', False) + if not isinstance(blockreenroll, bool): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'blockreenroll must be true or false') + reason = None + if blockreenroll: + reason, problem = _text_field(payload, 'reason', 64, False) + if problem is not None: + return error_response(ErrorCodes.VALIDATION_ERROR, problem) + reason = reason or None + credential = agentauth.active_credential_for(assetid) + if credential is None and blockreenroll: + credential = EndpointCredential.query.filter_by(assetid=assetid).first() if credential is None: return error_response(ErrorCodes.NOT_FOUND, 'This computer has no active credential', http_code=404) - credential.revokedat = _naive_utcnow() - credential.revokedby = _username() - AuditLog.log('deleted', 'EndpointCredential', entityid=assetid, - entityname=credential.credentialprefix, - details='endpoint credential {} revoked'.format( - credential.credentialprefix)) + + wasactive = credential.revokedat is None + if wasactive: + credential.revokedat = _naive_utcnow() + credential.revokedby = _username() + details = [] + if wasactive: + details.append('endpoint credential {} revoked'.format( + credential.credentialprefix)) + if blockreenroll: + credential.reenrollblocked = True + credential.reenrollblockedreason = reason + details.append('re-enrollment blocked ({})'.format(reason or 'no reason')) + AuditLog.log('deleted' if wasactive else 'updated', 'EndpointCredential', + entityid=assetid, entityname=credential.credentialprefix, + details='; '.join(details)) db.session.commit() return success_response(credential.to_dict(), message='Endpoint credential revoked') +@endpoints_bp.route('/enrollment/assets//allow-reenroll', + methods=['POST']) +@require_role('admin') +def allow_endpoint_reenroll(assetid): + """Lift a re-enrollment block. The PC enrolls on its next attempt.""" + credential = EndpointCredential.query.filter_by(assetid=assetid).first() + if credential is None: + return error_response(ErrorCodes.NOT_FOUND, + 'This computer has no credential record', + http_code=404) + if not credential.reenrollblocked: + return success_response(credential.to_dict(), + message='Re-enrollment was not blocked') + previous = credential.reenrollblockedreason + credential.reenrollblocked = False + credential.reenrollblockedreason = None + AuditLog.log('updated', 'EndpointCredential', entityid=assetid, + entityname=credential.credentialprefix, + changes={'reenrollblocked': {'old': True, 'new': False}}, + details='re-enrollment allowed again (was blocked: {})'.format( + previous or 'no reason')) + db.session.commit() + return success_response(credential.to_dict(), + message='Re-enrollment allowed') + + @endpoints_bp.route('/enrollment/settings', methods=['PUT']) @require_role('admin') def update_enrollment_settings(): @@ -546,6 +635,28 @@ def _store_has_package(): return not _version_below(current, CREDENTIAL_AGENT_VERSION) +def _latest_commands(assetids, target=None): + """The newest run_script command per asset (optionally for one target).""" + if not assetids: + return {} + newest = (db.session.query(db.func.max(EndpointCommand.endpointcommandid)) + .filter(EndpointCommand.assetid.in_(assetids), + EndpointCommand.action == 'run_script')) + if target is not None: + newest = newest.filter(EndpointCommand.target == target) + newest = newest.group_by(EndpointCommand.assetid) + return {command.assetid: command for command in EndpointCommand.query + .filter(EndpointCommand.endpointcommandid.in_(newest)).all()} + + +def _is_tls_trust_failure(command): + if command is None or command.status != EndpointCommand.STATUS_FAILED: + return False + text = '{} {}'.format(command.resultmessage or '', + command.resultjson or '').lower() + return any(marker in text for marker in _TLS_TRUST_FAILURES) + + def compute_readiness(assetids=None): """Migration state for every active computer (or just assetids). @@ -562,8 +673,11 @@ def compute_readiness(assetids=None): return [] ids = [row[0] for row in computers] - active = {c.assetid for c in EndpointCredential.query - .filter(EndpointCredential.revokedat.is_(None)).all()} + credentialrows = EndpointCredential.query.filter( + EndpointCredential.assetid.in_(ids)).all() + active = {c.assetid for c in credentialrows if c.revokedat is None} + reenrollblocked = {c.assetid for c in credentialrows + if c.reenrollblocked and c.revokedat is not None} index = _pending_index(_pending_requests()) states = {s.assetid: s for s in EndpointState.query.filter(EndpointState.assetid.in_(ids)).all()} @@ -579,6 +693,8 @@ def compute_readiness(assetids=None): (EndpointCommand.STATUS_PENDING, EndpointCommand.STATUS_RUNNING)))} + lastscripts = _latest_commands(ids) + haspackage = _store_has_package() minimum = minimum_agent_version_for('run_script') offlinecutoff = _naive_utcnow() - _OFFLINE_AFTER @@ -591,6 +707,9 @@ def compute_readiness(assetids=None): blockers = [] if assetid in active: status = 'enrolled' + elif assetid in reenrollblocked: + status = 'blocked' + blockers.append(BLOCKER_REENROLL_BLOCKED) elif _is_pending(assetid, hostname, index): status = 'pending' elif assetid in upgrading: @@ -610,6 +729,8 @@ def compute_readiness(assetids=None): blockers.append(BLOCKER_NO_PACKAGE) if minimum and not agent_meets_minimum(agentversion, minimum): blockers.append(BLOCKER_TOO_OLD) + if _is_tls_trust_failure(lastscripts.get(assetid)): + blockers.append(BLOCKER_CERT_UNTRUSTED) if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION): blockers.append(BLOCKER_AWAITING_ENROLLMENT) if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR( @@ -628,6 +749,13 @@ def compute_readiness(assetids=None): def _wave_progress(wave, scriptid): + """Live progress of one wave. + + failed counts a member only while it is still stuck: its newest agent + upgrade (from any wave or a single push) failed or expired, it is not + enrolled, and it does not report an agent at or above the credential + version. A PC a later wave upgraded stops counting here. + """ members = wave.assetidlist enrolled = set() failed = set() @@ -637,21 +765,23 @@ def _wave_progress(wave, scriptid): EndpointCredential.assetid.in_(members), EndpointCredential.revokedat.is_(None)).all()} if scriptid is not None: - commands = (EndpointCommand.query - .filter(EndpointCommand.assetid.in_(members), - EndpointCommand.action == 'run_script', - EndpointCommand.target == f'script:{scriptid}', - EndpointCommand.createdat >= wave.createdat) - .order_by(EndpointCommand.createdat.asc()) - .all()) - latest = {} - for command in commands: - queued.add(command.assetid) - latest[command.assetid] = command - failed = {assetid for assetid, command in latest.items() + target = f'script:{scriptid}' + queued = {row[0] for row in db.session.query(EndpointCommand.assetid) + .filter(EndpointCommand.assetid.in_(members), + EndpointCommand.action == 'run_script', + EndpointCommand.target == target, + EndpointCommand.createdat >= wave.createdat) + .distinct()} + versions = dict(db.session.query(EndpointState.assetid, + EndpointState.agentversion) + .filter(EndpointState.assetid.in_(members)).all()) + failed = {assetid for assetid, command + in _latest_commands(members, target).items() if assetid not in enrolled and command.status in (EndpointCommand.STATUS_FAILED, - EndpointCommand.STATUS_EXPIRED)} + EndpointCommand.STATUS_EXPIRED) + and _version_below(versions.get(assetid), + CREDENTIAL_AGENT_VERSION)} return { 'waveid': wave.waveid, 'createdat': _utciso(wave.createdat), diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 7003baeb..b97545cb 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -4164,6 +4164,14 @@ def acknowledge_fleet_alert(notification_id: int): return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname does not match provided assetid') + # Same targeting as the /alerts list. An alert this PC was never shown + # answers exactly like an unknown one, so the route cannot probe scopes. + facts = _endpoint_scope_facts(hostname) or {} + if not _alert_targets_endpoint(notification.alertscope, facts): + return error_response(ErrorCodes.NOT_FOUND, + f'Notification {notification_id} not found', + http_code=404) + # Check if already acknowledged (idempotent) existing = (NotificationAcknowledgement.query .filter_by(notificationid=notification_id, assetid=assetid) @@ -4196,6 +4204,21 @@ def acknowledge_fleet_alert(notification_id: int): # Server certificate management # ============================================================================= +def _sha1_thumbprint(pembytes): + """Uppercase hex SHA-1 of a PEM certificate, as Windows shows a thumbprint. + + None when the file does not parse. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import hashes + + try: + certificate = x509.load_pem_x509_certificate(pembytes) + except ValueError: + return None + return certificate.fingerprint(hashes.SHA1()).hex().upper() + + def _spki_sha256(pembytes): """Lowercase hex SHA-256 of a PEM certificate's SubjectPublicKeyInfo. @@ -4299,6 +4322,7 @@ def get_server_certificate(): 'not_after': not_after, 'thumbprint': thumbprint, 'spkisha256': _spki_sha256(cert_path.read_bytes()), + 'sha1thumbprint': _sha1_thumbprint(cert_path.read_bytes()), 'san_list': san_list, 'expires_in_days': expires_in_days, 'is_self_signed': is_self_signed @@ -4316,6 +4340,36 @@ def get_server_certificate(): http_code=500) +@endpoints_bp.route('/settings/server-cert/download', methods=['GET']) +@require_permission('endpoints.fleet') +def download_server_certificate(): + """The PUBLIC server certificate as a DER .cer file, never the key. + + For PCs on agents older than 1.20, which check the certificate chain when + they download the upgrade script: import this into the PC's Trusted Root + store before a migration wave reaches it. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + + cert_path = _server_cert_dir() / 'server.crt' + if not cert_path.exists(): + return error_response(ErrorCodes.NOT_FOUND, + 'Server certificate not found', http_code=404) + try: + certificate = x509.load_pem_x509_certificate(cert_path.read_bytes()) + except ValueError: + return error_response(ErrorCodes.INTERNAL_ERROR, + 'The server certificate file does not parse', + http_code=500) + body = certificate.public_bytes(serialization.Encoding.DER) + response = current_app.response_class(body, mimetype='application/pkix-cert') + response.headers['Content-Disposition'] = \ + 'attachment; filename="shopdb-server.cer"' + response.headers['Cache-Control'] = 'no-store' + return response + + _CERT_HOSTNAME_PATTERN = re.compile( r'^(\*\.)?(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?' r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$') diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py index c5d7fd28..9402e91f 100644 --- a/plugins/endpoints/api/siteagent.py +++ b/plugins/endpoints/api/siteagent.py @@ -21,11 +21,18 @@ The generated install.ps1 writes those two registry values from the operator's inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so the agent fails closed until provisioned. The collector key is embedded only in the install script's default parameter and is never stored or logged here. + +Per-PC credentials: the shared key is optional per build. A package built +without it carries no key at all; the agent then asks to enroll and an admin +approves it. Every package pins the server's public key (ServerPins, the +lowercase hex SHA-256 of the leaf SubjectPublicKeyInfo), optionally with a +second "next" pin for a planned certificate rotation. """ import io import json import os +import re import ssl import zipfile @@ -34,9 +41,12 @@ from flask import request, current_app, send_file from shopdb.api import (success_response, error_response, ErrorCodes, require_permission) -from .routes import endpoints_bp +from .routes import endpoints_bp, _spki_sha256 +from .agentauth import shared_key_allowed from . import agentstore +_PIN_PATTERN = re.compile(r'^[0-9a-f]{64}$') + # Prebuilt, staged agent binaries. Built out of band and dropped under # instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model). STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe') @@ -110,6 +120,49 @@ def _validate_pem_cert(raw): return None return ''.join(blocks) + +def _leaf_certificate_facts(pembytes): + """(spkisha256, isca) for the FIRST certificate in a PEM, else (None, None). + + The first block is the leaf in a leaf-plus-chain file, which is the cert + the agent pins and the one Import-Certificate brings into the store. + """ + from cryptography import x509 + + try: + text = pembytes.decode('ascii') + except (UnicodeDecodeError, AttributeError): + return None, None + start = text.find('-----BEGIN CERTIFICATE-----') + end = text.find('-----END CERTIFICATE-----', start) + if start == -1 or end == -1: + return None, None + block = (text[start:end + len('-----END CERTIFICATE-----')] + '\n').encode('ascii') + try: + certificate = x509.load_pem_x509_certificate(block) + except ValueError: + return None, None + try: + isca = bool(certificate.extensions.get_extension_for_class( + x509.BasicConstraints).value.ca) + except x509.ExtensionNotFound: + isca = False + return _spki_sha256(block), isca + + +def _current_server_pin(): + """Pin of this server's own leaf certificate, or None when not found/CA.""" + path = _server_cert_path() + if not path: + return None + try: + with open(path, 'rb') as handle: + pin, isca = _leaf_certificate_facts(handle.read()) + except OSError: + return None + return None if isca else pin + + # The non-secret runtime config shipped in the package. baseurl and apikey stay # empty/placeholder so the agent fails closed until the registry is provisioned # by install.ps1. Cadence is stamped from the operator's input at build time. @@ -175,20 +228,42 @@ FILES USAGE 1. Copy this whole folder to the target endpoint. 2. Right-click install.ps1 and Run with PowerShell as administrator. It - writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB, + {provision} to HKLM\\SOFTWARE\\GE\\ShopDB, copies the agent into Program Files, and starts the service. It also registers the tray to autostart for every user at logon and adds a "ShopDB Agent" Start Menu shortcut for manual relaunch. - +{enrollment} UNINSTALL Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the agent, tray autostart, Start Menu shortcut, and credentials. SECURITY - install.ps1 carries a live collector key in its default -CollectorKey value. - Treat this package as a secret. The key is not recoverable from the ShopDB - server after it was generated; regenerate the key in the UI to revoke it. -""" +{security}""" + +_README_SHAREDKEY = { + True: { + 'provision': 'writes the server URL, server pins, and collector key', + 'enrollment': '', + 'security': ( + ' install.ps1 carries a live collector key in its default -CollectorKey value.\n' + ' Treat this package as a secret. The key is not recoverable from the ShopDB\n' + ' server after it was generated; regenerate the key in the UI to revoke it.\n'), + }, + False: { + 'provision': 'writes the server URL and server pins', + 'enrollment': ( + ' This package carries NO shared key. After install the PC requests\n' + ' enrollment; approve it in Settings > Endpoints > Enrollment.\n'), + 'security': ( + ' This package holds no secret. Each PC receives its own credential when\n' + ' an admin approves its enrollment request; an existing enrolled\n' + ' credential on the PC is never touched by install.ps1.\n'), + }, +} + + +def _readme(includesharedkey): + return _README.format(**_README_SHAREDKEY[bool(includesharedkey)]) def _staged_path(relpath): @@ -196,7 +271,9 @@ def _staged_path(relpath): return os.path.join(current_app.instance_path, relpath) -def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None): +def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None, + serverpins=None, includesharedkey=True, + trustcertinroot=True): """The install.ps1 that provisions credentials and installs the service. The operator's server URL and collector key are stamped as the default @@ -206,10 +283,34 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None): certificate is bundled, certfilename names it and the script imports it into the machine Trusted Root store so the agent's outbound TLS to a self-signed or internal-CA server validates. + + serverpins are written as REG_MULTI_SZ ServerPins. With includesharedkey + False the collector key is left out of the script entirely (no parameter, + no registry write) and the installer tells the operator to approve the + enrollment request. trustcertinroot False (a CA certificate) skips the Root + import; the script also re-checks basicConstraints on the endpoint. """ baseurl_literal = json.dumps(baseurl) - collectorkey_literal = json.dumps(collectorkey) certfile_literal = json.dumps(certfilename or '') + pins_literal = '@(' + ', '.join(json.dumps(pin) for pin in (serverpins or [])) + ')' + if includesharedkey: + keyparam = ' [string] $CollectorKey = {0},\n'.format(json.dumps(collectorkey)) + keycheck = ( + 'if ([string]::IsNullOrWhiteSpace($CollectorKey)) {\n' + ' throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."\n' + '}\n') + keywrite = 'Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey\n' + sharedkeymode = 'included' + enrollnotice = '' + else: + keyparam = '' + keycheck = '' + keywrite = ( + 'Write-Host " no shared key in this package; the agent will request enrollment." -ForegroundColor Yellow\n') + sharedkeymode = 'omitted' + enrollnotice = ( + 'Write-Host " This PC will request enrollment. Approve it in ' + 'Settings > Endpoints > Enrollment." -ForegroundColor Yellow\n') return """# install.ps1 # ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on # the endpoint. Provisions the registry credentials, copies the agent to @@ -219,7 +320,7 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None): [CmdletBinding()] param( [string] $BaseUrl = {baseurl}, - [string] $CollectorKey = {collectorkey}, +{keyparam} [string[]] $ServerPins = {serverpins}, [string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent", [string] $ServiceName = "ShopDbEndpointAgent", [string] $TrayExeName = "shopdb-endpoint-tray.exe", @@ -251,9 +352,8 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{ throw "BaseUrl is not set. Pass -BaseUrl 'http://:' when running the script." }} -if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{ - throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild." -}} +{keycheck}$sharedKeyMode = "{sharedkeymode}" +$trustCertInRoot = {trustcert} # Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views. # The agent reads Registry64 first then Registry32 and uses the first view that @@ -280,10 +380,55 @@ function Set-HklmValueBothViews {{ }} }} +# REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same +# pins leaves the value (and its last-write time) alone. +function Set-HklmMultiStringBothViews {{ + param( + [Parameter(Mandatory)] [string] $SubKey, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string[]] $Value + ) + $wanted = ($Value -join "`n") + foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, + [Microsoft.Win32.RegistryView]::Registry32)) {{ + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( + [Microsoft.Win32.RegistryHive]::LocalMachine, $view) + try {{ + $key = $base.CreateSubKey($SubKey) + try {{ + $same = $false + $existingValue = $key.GetValue($Name, $null) + if ($null -ne $existingValue -and + $key.GetValueKind($Name) -eq [Microsoft.Win32.RegistryValueKind]::MultiString) {{ + $same = ((@($existingValue) -join "`n") -ceq $wanted) + }} + if ($same) {{ + Write-Host " $Name unchanged ($view)" + }} + else {{ + $key.SetValue($Name, [string[]] $Value, [Microsoft.Win32.RegistryValueKind]::MultiString) + Write-Host " $Name written ($view)" + }} + }} + finally {{ $key.Dispose() }} + }} + finally {{ $base.Dispose() }} + }} +}} + Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan Write-Host "BaseUrl : $BaseUrl" Write-Host "InstallDir : $InstallDir" Write-Host "ServiceName : $ServiceName" +Write-Host "Shared key : $sharedKeyMode" + +# Per-PC credential lives under ProgramData\\ShopDB\\EndpointAgent\\credential and +# belongs to the agent. This installer never writes or deletes it, so a reinstall +# or upgrade keeps an enrolled PC enrolled. +$credentialDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential" +if (Test-Path (Join-Path $credentialDir "credential.bin")) {{ + Write-Host " existing enrolled credential found; kept as is." +}} # 0. Stop and remove any previous install so the file copy is a clean overwrite. $existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue @@ -306,21 +451,48 @@ foreach ($name in @("shopdbagent", $trayBase)) {{ # 1. Provision registry credentials (both 64-bit and 32-bit views). Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..." Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl -Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey +{keywrite} +# Server public-key pins (current, plus next during a planned rotation). An +# empty list leaves any ServerPins already on the PC untouched. +$pinList = @($ServerPins | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }}) +if ($pinList.Count -gt 0) {{ + Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins" -Value $pinList +}} +else {{ + Write-Warning "no server pin in this package; ServerPins left as is." +}} # 1a. Trust the bundled server certificate, if one shipped. The agent's outbound # TLS to a self-signed or internal-CA server fails closed until the machine # trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service. # No-op when the deployment uses a publicly trusted (or already-trusted internal) # certificate, in which case no cert is bundled. +# Kept for agents older than 1.20 and PowerShell download paths that do not pin. +# Only a leaf is trusted: a CA in Root would trust everything it signs. if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{ $certPath = Join-Path $sourceDir $CertFileName if (Test-Path $certPath) {{ - Write-Host " trusting bundled server certificate $CertFileName ..." - try {{ - Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null + $certIsCa = -not $trustCertInRoot + if (-not $certIsCa) {{ + try {{ + $probe = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath) + foreach ($ext in $probe.Extensions) {{ + if ($ext -is [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension] -and + $ext.CertificateAuthority) {{ $certIsCa = $true }} + }} + }} + catch {{ }} + }} + if ($certIsCa) {{ + Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root." + }} + else {{ + Write-Host " trusting bundled server certificate $CertFileName ..." + try {{ + Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null + }} + catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }} }} - catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }} }} else {{ Write-Warning "server certificate $CertFileName named but not found beside install.ps1." @@ -578,7 +750,7 @@ if (-not $serviceOk) {{ throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved." }} Write-Host " service is running." -ForegroundColor Green - +{enrollnotice} $trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName) if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{ # Report only; never relaunch from here. This block used to Start-Process the @@ -601,8 +773,8 @@ if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{ # 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under # ProgramData so the uninstaller and appsettings/cert are available later, then -# scrub install.ps1 from wherever it was run. install.ps1 carries the live -# collector key in its default parameter, so it must not linger on the endpoint. +# scrub install.ps1 from wherever it was run. A shared-key build carries the live +# key in its default parameter, so it must not linger on the endpoint. Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..." $dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent" New-Item -ItemType Directory -Force -Path $dataDir | Out-Null @@ -618,8 +790,8 @@ $uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1" $installSelf = Join-Path $sourceDir "install.ps1" if (Test-Path $installSelf) {{ - try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }} - catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }} + try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1." }} + catch {{ Write-Warning "could not remove install.ps1 - delete it manually: $($_.Exception.Message)" }} }} Write-Host "[6/6] Done." -ForegroundColor Green @@ -639,7 +811,7 @@ try {{ $ledgerTrayVersion = "not-installed" $ledgerTrayPath = Join-Path $InstallDir $TrayExeName if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }} - $ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState + $ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}} sharedkey={{7}} serverpins={{8}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState, $sharedKeyMode, $pinList.Count Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8 Write-Host "Install ledger: $ledgerPath" Write-Host " $ledgerLine" -ForegroundColor DarkGray @@ -655,7 +827,13 @@ else {{ }} """.format( baseurl=baseurl_literal, - collectorkey=collectorkey_literal, + keyparam=keyparam, + keycheck=keycheck, + keywrite=keywrite, + sharedkeymode=sharedkeymode, + enrollnotice=enrollnotice, + serverpins=pins_literal, + trustcert='$true' if trustcertinroot else '$false', certfile=certfile_literal, ) @@ -672,6 +850,8 @@ def _siteagent_status_payload(): 'agentstaged': agent_staged, 'traystaged': tray_staged, 'certavailable': _server_cert_path() is not None, + 'serverpin': _current_server_pin(), + 'sharedkeyallowed': shared_key_allowed(), 'agentcurrent': agentstore.current_version('agent'), 'traycurrent': agentstore.current_version('tray'), 'agentversions': agentstore.list_versions('agent'), @@ -694,7 +874,12 @@ def package_siteagent(): """Bundle the site agent as a downloadable ZIP. Accepts either JSON or multipart/form-data with fields baseurl, - collectorkey, cadenceseconds, and bundlecert. A multipart request may also + collectorkey, cadenceseconds, bundlecert, includesharedkey, and + nextserverpin. includesharedkey defaults to the site setting + plugin.endpoints.sharedkeyallowed; when off, collectorkey is ignored and + nothing in the package carries it. nextserverpin (64 lowercase hex) is + shipped beside the current server pin for a planned certificate rotation. + A multipart request may also carry a certfile upload: an operator-supplied .crt/.pem that is validated as X.509 and bundled so the installer trusts it as a root on the endpoint. This is the reverse-proxy path where the app cannot see its own TLS cert. When no @@ -717,11 +902,25 @@ def package_siteagent(): collectorkey = (source.get('collectorkey') or '').strip() cadenceseconds = source.get('cadenceseconds', 900) bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on') + rawinclude = source.get('includesharedkey') + if rawinclude is None or str(rawinclude).strip() == '': + includesharedkey = shared_key_allowed() + else: + includesharedkey = str(rawinclude).lower() in ('1', 'true', 'yes', 'on') + nextserverpin = (source.get('nextserverpin') or '').strip() - if not baseurl or not collectorkey: + if not includesharedkey: + collectorkey = '' + if not baseurl or (includesharedkey and not collectorkey): return error_response( ErrorCodes.VALIDATION_ERROR, - 'baseurl and collectorkey are required', + 'baseurl and collectorkey are required' if includesharedkey + else 'baseurl is required', + http_code=400) + if nextserverpin and not _PIN_PATTERN.match(nextserverpin): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'nextserverpin must be 64 lowercase hex characters', http_code=400) try: @@ -739,6 +938,8 @@ def package_siteagent(): appsettings = dict(_BASE_APPSETTINGS) appsettings['cadenceseconds'] = cadenceseconds + if not includesharedkey: + appsettings['apikey'] = '' # An operator-uploaded cert wins over the auto-detected server cert: it is # the explicit reverse-proxy answer where the app cannot see its own TLS @@ -758,14 +959,43 @@ 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. + trustcertinroot = True + currentpin = None + 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) + trustcertinroot = not bundledisca + if not bundledisca: + currentpin = bundledpin + if currentpin is None: + currentpin = _current_server_pin() + 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 = list(dict.fromkeys(serverpins)) + appsettings['serverpins'] = serverpins + buffer = io.BytesIO() with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive: archive.writestr('appsettings.json', json.dumps(appsettings, indent=2) + '\n') archive.writestr('install.ps1', _install_script(baseurl.rstrip('/'), collectorkey, - cadenceseconds, certfilename)) - archive.writestr('README.txt', _README) + cadenceseconds, certfilename, + serverpins=serverpins, + includesharedkey=includesharedkey, + trustcertinroot=trustcertinroot)) + archive.writestr('README.txt', _readme(includesharedkey)) if os.path.isfile(UNINSTALLER_PATH): archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1') diff --git a/plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 b/plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 index f3fb9d27..b7ca75fc 100644 --- a/plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 +++ b/plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 @@ -126,6 +126,11 @@ public static class ShopDbServerPinValidator return Pins.Contains(LastPresented); } + public static bool IsPinned(string pin) + { + return !String.IsNullOrEmpty(pin) && Pins.Contains(pin); + } + // Walks the DER: Certificate > TBSCertificate > [0] version (optional), // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo. public static string SpkiSha256Hex(byte[] der) @@ -264,8 +269,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) { try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {} } $presented = '' - if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented } - return @{ Status = $status; Code = $code; PresentedPin = $presented } + $matched = $false + if ('ShopDbServerPinValidator' -as [type]) { + $presented = [string][ShopDbServerPinValidator]::LastPresented + $matched = [ShopDbServerPinValidator]::IsPinned($presented) + } + # Innermost exception text: the outer WebException hides the real cause + # (unexpected EOF, connection reset, trust failure). + $message = '' + $exception = $ErrorRecord.Exception + while ($exception) { + if ($exception.Message) { $message = $exception.Message } + $exception = $exception.InnerException + } + return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message } } function Write-ShopDbAuthFailure($Failure) { @@ -285,7 +302,14 @@ function Write-ShopDbAuthFailure($Failure) { } default { if ($Failure.Status -eq 0 -and $Failure.PresentedPin) { - Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins" + # A pin was seen, but only an absent pin is a pin failure. A + # matched pin means the handshake passed and something later + # failed (server dropped mid-transfer, timeout). + if ($Failure.PinMatched) { + Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)" + } else { + Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins" + } } } } diff --git a/plugins/endpoints/client/Install-ShopDBServerCertificate.ps1 b/plugins/endpoints/client/Install-ShopDBServerCertificate.ps1 new file mode 100644 index 00000000..4f69cf34 --- /dev/null +++ b/plugins/endpoints/client/Install-ShopDBServerCertificate.ps1 @@ -0,0 +1,194 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Trust the ShopDB server's own certificate on this PC by adding it to + LocalMachine\Root, after checking it is the expected certificate and not a + certificate authority. + +.DESCRIPTION + WHY: endpoint agents older than 1.20 check the normal Windows certificate + chain when they download the upgrade package. They know nothing about + ServerPins. A server with a self-signed certificate is therefore refused + by those agents, and the upgrade to 1.20 never lands. The certificate has + to be trusted on the PC BEFORE a store wave queues the upgrade. + + PCs installed by the server's generated installer already trust it. PCs + installed from the share do not, and need this entry. + + Adding a non-CA leaf certificate to Root trusts exactly that one server. + It cannot be used to vouch for any other site, so the blast radius is the + server itself. A CA certificate in Root would trust everything that CA + signs, which is why a CA is refused unless -AllowCa is passed. + + Get the .cer from Settings > Endpoints > Server certificate (download), or + GET /api/endpoints/settings/server-cert/download. The expected thumbprint + is the sha1thumbprint field of GET /api/endpoints/settings/server-cert. + + Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, in the same + targeting as the Set-ShopDBServerPins.ps1 entry. Idempotent: the Root store + is checked first and nothing is written when the certificate is already + there, so it is safe every cycle. Nothing else is removed or changed. + +.PARAMETER CertPath + Path to the server certificate file, DER or PEM (.cer / .crt / .pem). + +.PARAMETER Thumbprint + Expected SHA-1 thumbprint of the certificate. Spaces, colons and case are + ignored. The file is refused when it does not match. + +.PARAMETER AllowCa + Import even when the certificate is a CA (basicConstraints CA=true). Only + for a site that deliberately trusts its own private CA. + +.PARAMETER LogPath + Append a transcript here as well as writing to the pipeline. + +.EXAMPLE + .\Install-ShopDBServerCertificate.ps1 -CertPath \shopdb-server.cer -Thumbprint + Trust the server certificate on this PC. + +.EXAMPLE + .\Install-ShopDBServerCertificate.ps1 -CertPath .\shopdb-server.cer -Thumbprint -WhatIf + Show what would be imported without touching the store. + +.NOTES + Exit codes: + 0 imported, or already trusted + 1 bad input, mismatch, refused CA, or an error stopped the import +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)] + [string]$CertPath, + [Parameter(Mandatory = $true)] + [string]$Thumbprint, + [switch]$AllowCa, + [string]$LogPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Log { + param([string]$Message) + $line = "{0} [Install-ShopDBServerCertificate] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message + Write-Output $line + if ($LogPath) { + try { Add-Content -LiteralPath $LogPath -Value $line -WhatIf:$false -ErrorAction Stop } catch { } + } +} + +function Read-Certificate { + param([string]$Path) + $bytes = [System.IO.File]::ReadAllBytes($Path) + $text = [System.Text.Encoding]::ASCII.GetString($bytes) + $match = [regex]::Match($text, '-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----', + [System.Text.RegularExpressions.RegexOptions]::Singleline) + if ($match.Success) { + $bytes = [Convert]::FromBase64String(($match.Groups[1].Value -replace '\s', '')) + } + return New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,[byte[]]$bytes) +} + +# --- 1. validate the request ------------------------------------------------ + +$expected = ($Thumbprint -replace '[\s:]', '').ToUpperInvariant() +if ($expected -notmatch '^[0-9A-F]{40}$') { + Log "ERROR '$Thumbprint' is not a SHA-1 thumbprint (40 hex characters); nothing imported" + exit 1 +} + +if (-not (Test-Path -LiteralPath $CertPath -PathType Leaf)) { + Log "ERROR certificate file not found: $CertPath" + exit 1 +} + +try { + $cert = Read-Certificate -Path $CertPath +} catch { + Log "ERROR could not read a certificate from $CertPath : $($_.Exception.Message)" + exit 1 +} + +$actual = $cert.Thumbprint.ToUpperInvariant() +Log "file: $CertPath" +Log "subject: $($cert.Subject); issuer: $($cert.Issuer); expires: $($cert.NotAfter.ToString('yyyy-MM-dd'))" +if ($actual -ne $expected) { + Log "ERROR thumbprint of the file is $actual, expected $expected; refusing to import" + exit 1 +} + +if ($cert.HasPrivateKey) { + Log 'ERROR the file carries a private key; export the public certificate only' + exit 1 +} + +# --- 2. refuse a CA --------------------------------------------------------- + +$isCa = $false +foreach ($extension in $cert.Extensions) { + if ($extension.Oid.Value -eq '2.5.29.19') { + $constraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]$extension + $isCa = $constraints.CertificateAuthority + } +} +if ($isCa) { + if (-not $AllowCa) { + Log ('ERROR the certificate is a CA (basicConstraints CA=true). In Root it would trust every ' + + 'certificate it signs, not just the ShopDB server. Refusing; pass -AllowCa only if that is intended.') + exit 1 + } + Log 'WARN the certificate is a CA and -AllowCa was passed; everything it signs will be trusted' +} else { + Log 'not a CA (basicConstraints CA absent or false); trusts only this server' +} + +if ($cert.Subject -ne $cert.Issuer) { + Log 'WARN the certificate is not self-signed; Windows may still need its issuer to build the chain' +} +if ($cert.NotAfter -lt (Get-Date)) { + Log 'WARN the certificate has expired; the server needs a new one, importing anyway' +} + +# --- 3. import into LocalMachine\Root --------------------------------------- + +$store = New-Object System.Security.Cryptography.X509Certificates.X509Store( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) +try { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + try { + $present = $store.Certificates.Find( + [System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false) + $alreadyTrusted = $present.Count -gt 0 + } finally { $store.Close() } + + if ($alreadyTrusted) { + Log "LocalMachine\Root already holds $actual; nothing to do" + exit 0 + } + + if ($PSCmdlet.ShouldProcess("Cert:\LocalMachine\Root", "Import $actual ($($cert.Subject))")) { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + try { $store.Add($cert) } finally { $store.Close() } + + # Read back rather than trust Add. + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + try { + $after = $store.Certificates.Find( + [System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false) + } finally { $store.Close() } + if ($after.Count -eq 0) { + Log "ERROR $actual did not read back from LocalMachine\Root after import" + exit 1 + } + Log "imported $actual into LocalMachine\Root" + } +} catch { + Log "ERROR $($_.Exception.Message)" + exit 1 +} finally { + $store.Close() +} + +exit 0 diff --git a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md index e33c45e7..3d49b980 100644 --- a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md +++ b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md @@ -76,6 +76,8 @@ Then widen by hostname list (or machine number list) in batches: ``` 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. **Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and `HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ) @@ -83,9 +85,56 @@ equal to the pin on the settings page, and the next `report-asset-*.log` under `C:\Logs\Shopfloor` says `server pin check on (1 pin(s))` followed by a RESPONSE line. -**Rotating the server key later:** put the old AND the new pin in `-Pins` -(`","`), wait until every PC has both, switch the certificate, -then drop the old pin. +**Rotating the server key later:** any server-side change that gives the +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` +(`","`), wait until every PC has both, only then switch the +certificate on the server, then drop the old pin. 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 +certificate imported before the switch. + +### Trust the server certificate on PCs with agents older than 1.20 + +Required before ANY store wave. Agents older than 1.20 know nothing about +`ServerPins`: when they download the upgrade package they check the normal +Windows certificate chain. With a self-signed server certificate that check +fails and the upgrade never lands. + +- PCs installed by the server's generated installer (Build site agent) already + trust the certificate. +- PCs installed from the share do NOT, and need this entry. + +Get the certificate and its thumbprint from the server: + +- Settings -> Endpoints -> Server Certificate -> download, or + `GET /api/endpoints/settings/server-cert/download` (a `.cer`). +- The thumbprint is `sha1thumbprint` on `GET /api/endpoints/settings/server-cert`. + +Stage the `.cer` and `plugins/endpoints/client/Install-ShopDBServerCertificate.ps1` +on the share and add a PS1 entry with the SAME targeting as the pin entry: + +```json +{ + "_comment": "Trust the ShopDB server certificate for pre-1.20 agent upgrades.", + "Name": "ShopDB server certificate", + "Type": "PS1", + "Script": "scripts/Install-ShopDBServerCertificate.ps1", + "Args": "-CertPath \\scripts\\shopdb-server.cer -Thumbprint ", + "DetectionMethod": "Always", + "TargetMachineNumbers": [""] +} +``` + +The script refuses a file whose thumbprint differs, refuses a CA certificate +(one in Root would trust everything it signs), imports into +`LocalMachine\Root` only when the certificate is not already there, and removes +nothing. A non-CA leaf in Root trusts only this one server. + +If a wave reaches a PC that does not trust the certificate, the download fails +and the Migration tab marks the PC **Blocked** with `server certificate not +trusted by this PC`. Fix trust with the entry above, then queue the PC again. ## Phase 2: re-stamp the share's agent entries @@ -158,7 +207,10 @@ Repeat until **Ready** is 0. `no serial on record` / `duplicate hostname`: fix the computer record. `commands locked`: release the endpoint lock or policy hold. `no agent package in store`: upload 1.20. `agent too old for remote upgrade`: move it via the - share (targeted agent entry as in phase 2). + share (targeted agent entry as in phase 2). `server certificate not trusted + by this PC`: add the PC to the certificate entry (phase 1), wait a cycle, + queue it again. `reenroll blocked`: the PC was rolled back on purpose; clear + it with allow-reenroll (see Rollback) only when moving it forward again. - **Not reporting:** the PC has no working agent. It can only move via the share channel: add its hostname to a targeted 1.20 agent entry. It shows as Pending or Enrolled once the agent runs. @@ -208,22 +260,49 @@ read it until this table is done. ## Rollback +The order matters. A 1.20 agent that is plainly un-enrolled enrolls again +within about a second, before any downgrade can land, and the old agent it is +then replaced with only knows the shared key, which the server refuses for an +enrolled PC. Block re-enrollment FIRST. + **One PC:** 1. Make sure **Shared key allowed** is on. -2. Credentials tab -> **Un-enroll** the PC. Its credential stops working - immediately; the server admits the shared key for it again. -3. Push the previous agent: Upload New/Legacy Agents -> make the legacy build - current and upgrade that PC, or re-stamp its share entry to the old hash. -4. Remove the PC from the pin entry's targets only if pinning itself is the +2. Credentials tab -> **Un-enroll** the PC with **Keep this PC from enrolling + again** ticked. API equivalent: + `POST /api/endpoints/enrollment/assets//revoke` + `{"blockreenroll": true, "reason": "rollback"}`. + Its credential stops working immediately, the server admits the shared key + for it again, and its enroll attempts stay pending with `reenroll blocked`. +3. Make the previous agent current in the store (Upload New/Legacy Agents) and + queue the upgrade for that PC, or re-stamp its share entry to the old hash. +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). - -A 1.20 agent that is un-enrolled but NOT downgraded simply enrolls again on its -next cycle; that is expected. +6. Later, when moving the PC forward again, lift the block: + `POST /api/endpoints/enrollment/assets//allow-reenroll` (or the + same action in the UI). The PC enrolls on its next attempt once it runs + 1.20. Until then the Migration tab shows it blocked with `reenroll blocked`. **Whole fleet:** turn **Shared key allowed** back on first. Every PC that still -holds the shared key reports again immediately. Then un-enroll and downgrade -PCs as above, in waves. +holds the shared key reports again immediately. Then revoke with the block and +downgrade PCs as above, in waves. + +## Verified on Windows + +A full pass on a Windows test PC against a test server proved: + +- the agent enrolls on its own; +- the shared key is refused for the enrolled PC (`SHARED_KEY_REFUSED`); +- a request naming another asset is refused (`ASSET_MISMATCH`); +- server pins are enforced; +- the tray pipe works for a standard (non-admin) user; +- alert acknowledge works; +- both SYSTEM reporters send with the per-PC credential; +- revoke is followed by re-enrollment; +- rollback to the previous agent works; +- with the shared key switched off, enrolled PCs keep working. ## Quick reference diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index 235ebd96..9350bc48 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -222,10 +222,11 @@

Produce an instance-locked agent package stamped with this server's - URL, the endpoint agent key, and the reporting cadence, so it deploys - with no manual configuration on the client. The download includes the - agent, a non-secret appsettings.json, and an - install.ps1 that writes the credentials to the registry + URL, server pin, optionally the endpoint agent key, and the reporting + cadence, so it deploys with no manual configuration on the client. The + download includes the agent, a non-secret + appsettings.json, and an install.ps1 that + writes the settings to the registry (HKLM\SOFTWARE\GE\ShopDB) - the key never lands in appsettings.

@@ -245,14 +246,46 @@ placeholder="https://shopdb.example" /> - + + + + + +

+ Current server pin: + {{ agentserverpin || 'not available (no server certificate found)' }}. + Add the next certificate's pin only ahead of a planned certificate + rotation. +

+

+ The next server pin must be exactly 64 lowercase hex characters. +

@@ -518,7 +572,9 @@ function waveConfirmText(count) { return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n` + 'Each PC downloads the new agent and restarts its service. Once the new ' + 'agent is running it enrolls automatically with the shared key it already ' - + 'holds; no further action is needed unless it lands in Pending.' + + 'holds; no further action is needed unless it lands in Pending.\n\n' + + 'PCs on agents older than 1.20 must trust the server certificate before ' + + 'they can download the upgrade (see runbook).' } async function startWave(body, count) { @@ -729,15 +785,37 @@ async function reject(request) { } } -async function revoke(row) { - const fallback = enrollmentSettings.value.sharedkeyallowed +const revoking = ref(null) + +const revokeFallback = computed(() => { + if (revoking.value?.block) { + return 'It stays un-enrolled and keeps reporting on the shared key while that is allowed.' + } + return enrollmentSettings.value.sharedkeyallowed ? 'It falls back to the shared key and enrolls again on its own.' : 'The shared key is off, so it cannot report until an admin approves its new request.' - if (!confirm(`Un-enroll ${row.hostname}?\n\nIts credential stops working immediately. ${fallback}`)) return +}) + +function openRevoke(row) { + revoking.value = { assetid: row.assetid, hostname: row.hostname, block: false, reason: '' } +} + +async function confirmRevoke() { + const current = revoking.value + if (!current) return busy.value = true try { - await api.post(`/endpoints/enrollment/assets/${row.assetid}/revoke`) - toast.success(`Un-enrolled ${row.hostname}`) + const url = `/endpoints/enrollment/assets/${current.assetid}/revoke` + if (current.block) { + const body = { blockreenroll: true } + const reason = (current.reason || '').trim() + if (reason) body.reason = reason + await api.post(url, body) + } else { + await api.post(url) + } + toast.success(`Un-enrolled ${current.hostname}`) + revoking.value = null await loadEnrollment({ quiet: true }) } catch (error) { toast.error(apiError(error, 'Could not un-enroll')) @@ -746,6 +824,21 @@ async function revoke(row) { } } +async function allowReenroll(row) { + if (!confirm(`Allow ${row.hostname} to enroll again?\n\n` + + 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return + busy.value = true + try { + await api.post(`/endpoints/enrollment/assets/${row.assetid}/allow-reenroll`) + toast.success(`${row.hostname} may enroll again`) + await loadEnrollment({ quiet: true }) + } catch (error) { + toast.error(apiError(error, 'Could not allow re-enrollment')) + } finally { + busy.value = false + } +} + // ---- Shared ----------------------------------------------------------- function stateLabel(state) { @@ -982,6 +1075,10 @@ onBeforeUnmount(() => { .approve-form .form-control { max-width: 260px; } +.block-chip { margin-left: 0.35rem; } + +.revoke-row .form-control { max-width: 260px; } + .mono { font-family: 'Courier New', monospace; font-size: 13px; diff --git a/plugins/endpoints/frontend/views/enrollmentSettings.spec.js b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js index 069cc9bd..9c1d59e2 100644 --- a/plugins/endpoints/frontend/views/enrollmentSettings.spec.js +++ b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js @@ -54,7 +54,11 @@ function enrollmentPayload() { counts: { enrolled: 40, notenrolled: 58, pending: 2 }, computers: [ { assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled', - credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto' } + credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto', + reenrollblocked: false, reenrollblockedreason: null }, + { assetid: 22, hostname: 'PC-ROLLEDBACK', serialnumber: 'S2', state: 'revoked', + credentialprefix: 'sdbe_hijklmn', issuedat: null, lastusedat: null, enrolledvia: 'auto', + reenrollblocked: true, reenrollblockedreason: 'rollback' } ], requests: [ { requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch', @@ -125,6 +129,8 @@ describe('EnrollmentSettings', () => { expect(confirmSpy).toHaveBeenCalledTimes(1) expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/) expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/) + expect(confirmSpy.mock.calls[0][0]).toMatch( + /older than 1\.20 must trust the server certificate before they can download the upgrade/) expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 }) }) @@ -166,14 +172,46 @@ describe('EnrollmentSettings', () => { }) it('un-enrolls through the revoke route', async () => { - vi.stubGlobal('confirm', vi.fn(() => true)) const wrapper = await render() await openCredentials(wrapper) await wrapper.find('.revoke').trigger('click') + expect(api.post).not.toHaveBeenCalled() + expect(wrapper.find('.revoke-row').text()).toContain('Keep this PC from enrolling again') + expect(wrapper.find('.revoke-block').element.checked).toBe(false) + await wrapper.find('.revoke-confirm').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke') }) + it('sends blockreenroll with the reason when the block box is ticked', async () => { + const wrapper = await render() + await openCredentials(wrapper) + await wrapper.find('.revoke').trigger('click') + await wrapper.find('.revoke-block').setValue(true) + await wrapper.find('.revoke-reason').setValue(' rollback ') + await wrapper.find('.revoke-confirm').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke', + { blockreenroll: true, reason: 'rollback' }) + }) + + it('shows the block chip and allows re-enrollment after confirm', async () => { + const confirmSpy = vi.fn(() => true) + vi.stubGlobal('confirm', confirmSpy) + const wrapper = await render() + await openCredentials(wrapper) + const rows = wrapper.findAll('.credential-row') + const blocked = rows.find(r => r.text().includes('PC-ROLLEDBACK')) + const enrolled = rows.find(r => r.text().includes('PC-ENROLLED')) + expect(blocked.find('.block-chip').text()).toBe('Re-enroll blocked') + expect(enrolled.find('.block-chip').exists()).toBe(false) + expect(enrolled.find('.allow-reenroll').exists()).toBe(false) + await blocked.find('.allow-reenroll').trigger('click') + await flushPromises() + expect(confirmSpy).toHaveBeenCalledTimes(1) + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/22/allow-reenroll') + }) + it('shows the not-enrolled count before turning the shared key off', async () => { const promptSpy = vi.fn(() => 'OFF') vi.stubGlobal('prompt', promptSpy) diff --git a/plugins/endpoints/migrations/versions/0023_endpoint_credentials.py b/plugins/endpoints/migrations/versions/0023_endpoint_credentials.py index 32d1b8e3..86e97749 100644 --- a/plugins/endpoints/migrations/versions/0023_endpoint_credentials.py +++ b/plugins/endpoints/migrations/versions/0023_endpoint_credentials.py @@ -12,8 +12,8 @@ builds of 0021/0022, and on a re-run: table where an earlier build of 0021/0022 created them. 2. Rename endpointapplications.app_fingerprint to appfingerprint where an earlier build of 0020 used the underscore spelling. -3. Create endpointcredentials, endpointenrollrequests and - endpointmigrationwaves. +3. Create endpointcredentials (with the reenrollblocked flag and its reason), + endpointenrollrequests and endpointmigrationwaves. MySQL 5.6 safe: no JSON columns, and the enroll request lookup index uses prefix lengths so the key stays under 767 bytes on utf8mb4. @@ -109,12 +109,26 @@ def upgrade(): sa.Column('lastusedat', sa.DateTime(), nullable=True), sa.Column('revokedat', sa.DateTime(), nullable=True), sa.Column('revokedby', sa.String(length=64), nullable=True), + sa.Column('reenrollblocked', sa.Boolean(), nullable=False, + server_default=sa.false()), + sa.Column('reenrollblockedreason', sa.String(length=64), + nullable=True), sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('credentialid'), sa.UniqueConstraint('assetid'), sa.UniqueConstraint('credentialhash'), ) + else: + # A test database that ran an earlier build of this revision. + columns = _columns(CREDENTIALS) + if 'reenrollblocked' not in columns: + op.add_column(CREDENTIALS, sa.Column( + 'reenrollblocked', sa.Boolean(), nullable=False, + server_default=sa.false())) + if 'reenrollblockedreason' not in columns: + op.add_column(CREDENTIALS, sa.Column( + 'reenrollblockedreason', sa.String(length=64), nullable=True)) if not _has_table(REQUESTS): op.create_table( diff --git a/plugins/endpoints/models/credential.py b/plugins/endpoints/models/credential.py index 58080d3b..93c7753f 100644 --- a/plugins/endpoints/models/credential.py +++ b/plugins/endpoints/models/credential.py @@ -4,7 +4,10 @@ One row per asset. Holds only the sha256 of the secret the agent was issued, plus a short display prefix. Revoking sets revokedat; re-issuing after a revoke UPDATES the same row, so - an asset never carries two credentials. + an asset never carries two credentials. reenrollblocked keeps + a revoked PC from enrolling again (rolling it back to an agent + that only knows the shared key, or a compromised PC) until an + admin allows it. EndpointEnrollRequest One row per (hostname, serial number) that asked to enroll. The @@ -49,6 +52,9 @@ class EndpointCredential(db.Model): lastusedat = db.Column(db.DateTime, nullable=True) revokedat = db.Column(db.DateTime, nullable=True) revokedby = db.Column(db.String(64), nullable=True) + reenrollblocked = db.Column(db.Boolean, nullable=False, default=False, + server_default=db.false()) + reenrollblockedreason = db.Column(db.String(64), nullable=True) @property def isactive(self): @@ -64,6 +70,8 @@ class EndpointCredential(db.Model): 'lastusedat': _utciso(self.lastusedat), 'revokedat': _utciso(self.revokedat), 'revokedby': self.revokedby, + 'reenrollblocked': bool(self.reenrollblocked), + 'reenrollblockedreason': self.reenrollblockedreason, } def __repr__(self): diff --git a/plugins/printers/client/Report-PrintersToShopDB.ps1 b/plugins/printers/client/Report-PrintersToShopDB.ps1 index 207bee77..5b83f2b9 100644 --- a/plugins/printers/client/Report-PrintersToShopDB.ps1 +++ b/plugins/printers/client/Report-PrintersToShopDB.ps1 @@ -141,6 +141,11 @@ public static class ShopDbServerPinValidator return Pins.Contains(LastPresented); } + public static bool IsPinned(string pin) + { + return !String.IsNullOrEmpty(pin) && Pins.Contains(pin); + } + // Walks the DER: Certificate > TBSCertificate > [0] version (optional), // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo. public static string SpkiSha256Hex(byte[] der) @@ -279,8 +284,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) { try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {} } $presented = '' - if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented } - return @{ Status = $status; Code = $code; PresentedPin = $presented } + $matched = $false + if ('ShopDbServerPinValidator' -as [type]) { + $presented = [string][ShopDbServerPinValidator]::LastPresented + $matched = [ShopDbServerPinValidator]::IsPinned($presented) + } + # Innermost exception text: the outer WebException hides the real cause + # (unexpected EOF, connection reset, trust failure). + $message = '' + $exception = $ErrorRecord.Exception + while ($exception) { + if ($exception.Message) { $message = $exception.Message } + $exception = $exception.InnerException + } + return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message } } function Write-ShopDbAuthFailure($Failure) { @@ -300,7 +317,14 @@ function Write-ShopDbAuthFailure($Failure) { } default { if ($Failure.Status -eq 0 -and $Failure.PresentedPin) { - Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins" + # A pin was seen, but only an absent pin is a pin failure. A + # matched pin means the handshake passed and something later + # failed (server dropped mid-transfer, timeout). + if ($Failure.PinMatched) { + Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)" + } else { + Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins" + } } } } diff --git a/tests/test_plugins/test_endpoints_credentials.py b/tests/test_plugins/test_endpoints_credentials.py index d73a4e29..e8f34387 100644 --- a/tests/test_plugins/test_endpoints_credentials.py +++ b/tests/test_plugins/test_endpoints_credentials.py @@ -267,6 +267,102 @@ def test_credential_is_invalid_after_revoke(client, db, app, keyheaders, assert reissued != secret +def test_revoke_with_block_keeps_the_pc_off_credentials(client, db, app, + keyheaders, + auth_headers): + from plugins.endpoints.models import EndpointCredential + + assetid = _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + + revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke', + json={'blockreenroll': True, 'reason': 'rollback'}, + headers=auth_headers) + assert revoke.status_code == 200, revoke.get_data(as_text=True) + data = revoke.get_json()['data'] + assert data['revokedat'] is not None + assert data['reenrollblocked'] is True + assert data['reenrollblockedreason'] == 'rollback' + + # The current agent asks again at once: refused, with the reason. + again = _enroll(client, keyheaders) + assert again.status_code == 202, again.get_data(as_text=True) + assert again.get_json()['data']['reason'] == 'reenroll blocked' + assert client.get('/api/endpoints/alerts', + headers={'X-Endpoint-Credential': secret}).status_code == 401 + + # An older agent on the shared key keeps working. + assert client.get('/api/endpoints/alerts?hostname=BAY-01', + headers=keyheaders).status_code == 200 + + listing = client.get('/api/endpoints/enrollment', + headers=auth_headers).get_json()['data'] + row = next(r for r in listing['computers'] if r['assetid'] == assetid) + assert row['reenrollblocked'] is True + assert row['reenrollblockedreason'] == 'rollback' + assert row['state'] == 'revoked' + + # Blocked also means an approval cannot hand one out. + requestid = again.get_json()['data']['requestid'] + approve = client.post( + f'/api/endpoints/enrollment/requests/{requestid}/approve', + json={'assetid': assetid}, headers=auth_headers) + assert approve.status_code == 409 + + allow = client.post( + f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll', + headers=auth_headers) + assert allow.status_code == 200, allow.get_data(as_text=True) + assert allow.get_json()['data']['reenrollblocked'] is False + row = EndpointCredential.query.filter_by(assetid=assetid).one() + assert row.reenrollblockedreason is None + + reissued = _credential(client, keyheaders) + assert reissued != secret + + +def test_blocked_pc_under_shared_key_off_is_refused_everything( + client, db, app, keyheaders, auth_headers): + assetid = _computer(db, 'BAY-01') + _credential(client, keyheaders) + client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke', + json={'blockreenroll': True}, headers=auth_headers) + _setting(db, 'plugin.endpoints.sharedkeyallowed', False) + + refused = client.get('/api/endpoints/alerts?hostname=BAY-01', + headers=keyheaders) + assert refused.status_code == 401 + assert _code(refused) == 'SHARED_KEY_REFUSED' + + +def test_revoke_block_validation_and_revoked_rows(client, db, app, keyheaders, + auth_headers): + assetid = _computer(db, 'BAY-01') + nocredential = _computer(db, 'BAY-02', serialnumber='SER-2') + url = f'/api/endpoints/enrollment/assets/{assetid}/revoke' + _credential(client, keyheaders) + + assert client.post(url, json={'blockreenroll': 'yes'}, + headers=auth_headers).status_code == 400 + assert client.post(url, json={'blockreenroll': True, 'reason': 'x' * 65}, + headers=auth_headers).status_code == 400 + + # Plain revoke, then block the already-revoked credential afterwards. + assert client.post(url, headers=auth_headers).status_code == 200 + assert client.post(url, headers=auth_headers).status_code == 404 + blocked = client.post(url, json={'blockreenroll': True}, + headers=auth_headers) + assert blocked.status_code == 200 + assert blocked.get_json()['data']['reenrollblocked'] is True + + assert client.post( + f'/api/endpoints/enrollment/assets/{nocredential}/revoke', + json={'blockreenroll': True}, headers=auth_headers).status_code == 404 + assert client.post( + f'/api/endpoints/enrollment/assets/{nocredential}/allow-reenroll', + headers=auth_headers).status_code == 404 + + def test_garbage_credential_is_invalid(client, db, app): response = client.get('/api/endpoints/commands/next', headers={'X-Endpoint-Credential': 'sdbe_nope'}) @@ -349,7 +445,7 @@ def test_command_result_for_own_command_needs_no_hostname(client, db, app, assert response.status_code == 200, response.get_data(as_text=True) -def _fleet_alert(db): +def _fleet_alert(db, alertscope=None): from plugins.notifications.models import Notification, NotificationType ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f', @@ -358,7 +454,7 @@ def _fleet_alert(db): db.session.flush() note = Notification(notificationtypeid=ntype.notificationtypeid, notification='Line 3 is down', alertfleet=True, - alerttimeout=60, isactive=True) + alerttimeout=60, isactive=True, alertscope=alertscope) db.session.add(note) db.session.commit() return note.notificationid @@ -402,6 +498,37 @@ def test_alert_ack_with_no_assetid_means_the_caller(client, db, app, notificationid=notificationid, assetid=assetid).count() == 1 +def test_alert_ack_refuses_an_alert_not_scoped_to_the_caller( + client, db, app, keyheaders): + from plugins.notifications.models import NotificationAcknowledgement + + _computer(db, 'BAY-01') + _computer(db, 'BAY-02', serialnumber='SER-2') + _computer(db, 'BAY-03', serialnumber='SER-3') + secret = _credential(client, keyheaders) + notificationid = _fleet_alert(db, alertscope={'hostname': ['BAY-02', + 'BAY-03']}) + url = f'/api/endpoints/alerts/{notificationid}/acknowledge' + unknown = client.post('/api/endpoints/alerts/999999/acknowledge', json={}, + headers={'X-Endpoint-Credential': secret}) + + bycredential = client.post(url, json={}, + headers={'X-Endpoint-Credential': secret}) + assert bycredential.status_code == 404 + assert bycredential.get_json()['data']['error']['code'] == \ + unknown.get_json()['data']['error']['code'] + + bysharedkey = client.post(url, json={'hostname': 'BAY-03'}, + headers=keyheaders) + assert bysharedkey.status_code == 201, bysharedkey.get_data(as_text=True) + + _computer(db, 'BAY-04', serialnumber='SER-4') + outside = client.post(url, json={'hostname': 'BAY-04'}, headers=keyheaders) + assert outside.status_code == 404 + assert NotificationAcknowledgement.query.filter_by( + notificationid=notificationid).count() == 1 + + def test_collector_computers_refuses_another_hostname(client, db, app, keyheaders): _computer(db, 'BAY-01') @@ -487,6 +614,8 @@ def test_contract_exports_answer_through_the_endpoints_plugin(client, db, app, ('post', '/api/endpoints/enrollment/requests/1/approve'), ('post', '/api/endpoints/enrollment/requests/1/reject'), ('post', '/api/endpoints/enrollment/assets/1/revoke'), + ('post', '/api/endpoints/enrollment/assets/1/allow-reenroll'), + ('get', '/api/endpoints/settings/server-cert/download'), ('put', '/api/endpoints/enrollment/settings'), ('post', '/api/endpoints/enrollment/migration/wave'), ]) @@ -586,4 +715,17 @@ def test_server_cert_reports_the_spki_pin(client, db, auth_headers, tmp_path, response = client.get('/api/endpoints/settings/server-cert', headers=auth_headers) assert response.status_code == 200, response.get_data(as_text=True) - assert response.get_json()['data']['spkisha256'] == expected + data = response.get_json()['data'] + assert data['spkisha256'] == expected + sha1 = certificate.fingerprint(hashes.SHA1()).hex().upper() + assert data['sha1thumbprint'] == sha1 + assert ':' not in data['sha1thumbprint'] + + download = client.get('/api/endpoints/settings/server-cert/download', + headers=auth_headers) + assert download.status_code == 200 + assert download.mimetype == 'application/pkix-cert' + assert 'attachment' in download.headers['Content-Disposition'] + assert '.cer' in download.headers['Content-Disposition'] + assert download.data == certificate.public_bytes(serialization.Encoding.DER) + assert b'PRIVATE' not in download.data diff --git a/tests/test_plugins/test_endpoints_credentials_migration.py b/tests/test_plugins/test_endpoints_credentials_migration.py index dd28ac2f..193bdf91 100644 --- a/tests/test_plugins/test_endpoints_credentials_migration.py +++ b/tests/test_plugins/test_endpoints_credentials_migration.py @@ -86,7 +86,8 @@ def _assert_head_schema(connection): credentialcolumns = {c['name'] for c in inspector.get_columns('endpointcredentials')} assert credentialcolumns == { 'credentialid', 'assetid', 'credentialhash', 'credentialprefix', - 'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby'} + 'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby', + 'reenrollblocked', 'reenrollblockedreason'} requestindexes = [tuple(ix['column_names']) for ix in inspector.get_indexes('endpointenrollrequests')] assert requestindexes.count(('hostname', 'serialnumber')) == 1 diff --git a/tests/test_plugins/test_endpoints_enrollment_migration.py b/tests/test_plugins/test_endpoints_enrollment_migration.py index 3f3d94e8..1381b7ab 100644 --- a/tests/test_plugins/test_endpoints_enrollment_migration.py +++ b/tests/test_plugins/test_endpoints_enrollment_migration.py @@ -276,3 +276,140 @@ def test_wave_without_ready_pcs_creates_nothing(client, db, app, auth_headers): def test_wave_validation(client, db, app, auth_headers, body): assert client.post(WAVE_URL, json=body, headers=auth_headers).status_code == 400 + + +def test_a_reenroll_blocked_pc_is_blocked_not_ready(client, db, app, + auth_headers): + from plugins.endpoints.api.enrollment import issue_credential + from plugins.endpoints.models import EndpointEnrollRequest + + _publish_agent() + assetid = _pc(db, 'PC-ROLLBACK') + issue_credential(assetid, 'auto') + db.session.commit() + response = client.post( + f'/api/endpoints/enrollment/assets/{assetid}/revoke', + json={'blockreenroll': True, 'reason': 'rollback'}, + headers=auth_headers) + assert response.status_code == 200, response.get_data(as_text=True) + # Its agent keeps asking; that request must not turn the row into pending. + db.session.add(EndpointEnrollRequest( + hostname='PC-ROLLBACK', serialnumber='SER', enrollnonce='n' * 40, + status='pending', reason='reenroll blocked', matchedassetid=assetid, + requestedat=_now(), lastseenat=_now())) + db.session.commit() + + row = _by_host(_rows(client, auth_headers))['PC-ROLLBACK'] + assert row['state'] == 'blocked' + assert row['blockers'] == ['reenroll blocked'] + + wave = client.post(WAVE_URL, json={'assetids': [assetid]}, + headers=auth_headers).get_json()['data'] + assert wave['skipped'] == [{'assetid': assetid, + 'reason': 'reenroll blocked'}] + + client.post(f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll', + headers=auth_headers) + assert _by_host(_rows(client, auth_headers))['PC-ROLLBACK']['state'] \ + == 'pending' + + +@pytest.mark.parametrize('message,detail', [ + ('Script download failed: The SSL connection could not be established, ' + 'see inner exception.', None), + ('Script download failed', {'error': 'The remote certificate is invalid ' + 'because of errors in the certificate chain: ' + 'UntrustedRoot'}), + ('Invoke-WebRequest: Could not establish trust relationship for the ' + 'SSL/TLS secure channel.', None), +]) +def test_a_tls_trust_failure_blocks_the_pc(client, db, app, auth_headers, + message, detail): + import json + + from plugins.endpoints.models import EndpointCommand + + _publish_agent() + assetid = _pc(db, 'PC-UNTRUSTED') + otherid = _pc(db, 'PC-OTHERFAIL') + agentregistry.register_current_agent_package() + scriptid = agentupgradescript.seed_upgrade_script()['scriptid'] + db.session.add(EndpointCommand( + assetid=assetid, action='run_script', target=f'script:{scriptid}', + status=EndpointCommand.STATUS_FAILED, resultmessage=message, + resultjson=json.dumps(detail) if detail is not None else None, + completedat=_now())) + db.session.add(EndpointCommand( + assetid=otherid, action='run_script', target=f'script:{scriptid}', + status=EndpointCommand.STATUS_FAILED, + resultmessage='Script exited with code 1', completedat=_now())) + db.session.commit() + + rows = _by_host(_rows(client, auth_headers)) + assert rows['PC-UNTRUSTED']['state'] == 'blocked' + assert rows['PC-UNTRUSTED']['blockers'] == [ + 'server certificate not trusted by this PC'] + assert rows['PC-OTHERFAIL']['state'] == 'ready' + + +def test_a_later_success_clears_the_tls_blocker(client, db, app, auth_headers): + from plugins.endpoints.models import EndpointCommand + + _publish_agent() + assetid = _pc(db, 'PC-FIXED') + agentregistry.register_current_agent_package() + scriptid = agentupgradescript.seed_upgrade_script()['scriptid'] + db.session.add(EndpointCommand( + assetid=assetid, action='run_script', target=f'script:{scriptid}', + status=EndpointCommand.STATUS_FAILED, + resultmessage='RemoteCertificateChainErrors', completedat=_now())) + db.session.flush() + db.session.add(EndpointCommand( + assetid=assetid, action='run_script', target='script:999', + status=EndpointCommand.STATUS_SUCCEEDED, completedat=_now())) + db.session.commit() + + assert _by_host(_rows(client, auth_headers))['PC-FIXED']['state'] == 'ready' + + +def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app, + auth_headers): + from plugins.endpoints.models import EndpointCommand, EndpointState + + _publish_agent() + upgraded = _pc(db, 'PC-UPGRADED') + retried = _pc(db, 'PC-RETRIED') + stuck = _pc(db, 'PC-STUCK') + first = client.post(WAVE_URL, json={'assetids': [upgraded, retried, stuck]}, + headers=auth_headers) + assert first.status_code == 201, first.get_data(as_text=True) + for command in EndpointCommand.query.all(): + command.status = EndpointCommand.STATUS_FAILED + db.session.commit() + assert _rows(client, auth_headers)['waves'][0]['failed'] == 3 + + second = client.post(WAVE_URL, json={'assetids': [upgraded, retried]}, + headers=auth_headers) + assert second.status_code == 201, second.get_data(as_text=True) + # The later wave replaced the agent on PC-UPGRADED, which restarted before + # reporting, so its command expired; it has not enrolled yet. PC-RETRIED's + # push reported success and is waiting to enroll on its old version string. + latest = {command.assetid: command for command in EndpointCommand.query + .filter_by(status=EndpointCommand.STATUS_PENDING).all()} + latest[upgraded].status = EndpointCommand.STATUS_EXPIRED + latest[retried].status = EndpointCommand.STATUS_SUCCEEDED + EndpointState.query.filter_by(assetid=upgraded).one().agentversion = '1.20.0' + db.session.commit() + + failed = {wave['waveid']: wave['failed'] + for wave in _rows(client, auth_headers)['waves']} + assert failed == {first.get_json()['data']['waveid']: 1, + second.get_json()['data']['waveid']: 0} + + # A newer failure counts again, in every wave the PC belongs to. + latest[retried].status = EndpointCommand.STATUS_FAILED + db.session.commit() + failed = {wave['waveid']: wave['failed'] + for wave in _rows(client, auth_headers)['waves']} + assert failed == {first.get_json()['data']['waveid']: 2, + second.get_json()['data']['waveid']: 1} diff --git a/tests/test_plugins/test_endpoints_siteagent_build.py b/tests/test_plugins/test_endpoints_siteagent_build.py index e69de29b..e3f78855 100644 --- a/tests/test_plugins/test_endpoints_siteagent_build.py +++ b/tests/test_plugins/test_endpoints_siteagent_build.py @@ -0,0 +1,212 @@ +"""Site agent package build: server pins, optional shared key, Root import gate. + +The package is the only place a PC gets its first trust anchor and (optionally) +the shared fleet key, so these tests read the actual ZIP the route returns +rather than the helper in isolation. +""" + +import datetime +import hashlib +import io +import json +import zipfile + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +from plugins.endpoints.api import agentstore, siteagent +from shopdb.core.models.setting import Setting + +PACKAGE_URL = '/api/endpoints/siteagent/package' +STATUS_URL = '/api/endpoints/siteagent' +SECRET = 'sharedkeyvalue-0123456789abcdef' + + +def _make_cert(isca): + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, + 'ca.test' if isca else 'server.test')]) + now = datetime.datetime.now(datetime.timezone.utc) + builder = (x509.CertificateBuilder() + .subject_name(name).issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=30))) + if isca: + builder = builder.add_extension( + x509.BasicConstraints(ca=True, path_length=None), critical=True) + certificate = builder.sign(key, hashes.SHA256()) + pem = certificate.public_bytes(serialization.Encoding.PEM) + spki = certificate.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo) + return pem, hashlib.sha256(spki).hexdigest() + + +@pytest.fixture(autouse=True) +def isolate(tmp_path, monkeypatch): + """Empty agent store and no auto-detected server certificate by default.""" + store = tmp_path / 'siteagent' + store.mkdir() + monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store)) + monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ()) + return tmp_path + + +@pytest.fixture +def servercert(tmp_path, monkeypatch): + """A self-signed leaf staged as this server's own certificate.""" + pem, pin = _make_cert(isca=False) + path = tmp_path / 'server.crt' + path.write_bytes(pem) + monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),)) + return pin + + +def _build(client, headers, **fields): + body = {'baseurl': 'https://shopdb.test', 'cadenceseconds': 900} + body.update(fields) + return client.post(PACKAGE_URL, json=body, headers=headers) + + +def _files(response): + assert response.status_code == 200, response.get_data(as_text=True)[:400] + archive = zipfile.ZipFile(io.BytesIO(response.data)) + return {name: archive.read(name) for name in archive.namelist()} + + +def test_installer_writes_current_server_pin(client, db, auth_headers, servercert): + files = _files(_build(client, auth_headers, collectorkey=SECRET, + includesharedkey=True, bundlecert=True)) + script = files['install.ps1'].decode('ascii') + + assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script + assert 'Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins"' in script + assert '[Microsoft.Win32.RegistryValueKind]::MultiString' in script + assert '-ceq $wanted' in script + settings = json.loads(files['appsettings.json']) + assert settings['serverpins'] == [servercert] + + +def test_pin_comes_from_server_cert_even_when_not_bundled( + client, db, auth_headers, servercert): + files = _files(_build(client, auth_headers, collectorkey=SECRET)) + assert 'server.crt' not in files + assert json.loads(files['appsettings.json'])['serverpins'] == [servercert] + + +def test_next_pin_is_shipped_beside_current(client, db, auth_headers, servercert): + nextpin = 'ab' * 32 + files = _files(_build(client, auth_headers, collectorkey=SECRET, + nextserverpin=nextpin)) + script = files['install.ps1'].decode('ascii') + + assert '@("{0}", "{1}")'.format(servercert, nextpin) in script + assert json.loads(files['appsettings.json'])['serverpins'] == [servercert, nextpin] + + +@pytest.mark.parametrize('badpin', ['AB' * 32, 'ab' * 31, 'zz' * 32]) +def test_next_pin_must_be_64_lowercase_hex(client, db, auth_headers, servercert, badpin): + response = _build(client, auth_headers, collectorkey=SECRET, nextserverpin=badpin) + assert response.status_code == 400 + + +def test_next_pin_without_a_current_pin_is_refused(client, db, auth_headers): + response = _build(client, auth_headers, collectorkey=SECRET, + nextserverpin='ab' * 32) + assert response.status_code == 400 + + +def test_shared_key_on_keeps_key_provisioning(client, db, auth_headers, servercert): + files = _files(_build(client, auth_headers, collectorkey=SECRET, + includesharedkey='true')) + script = files['install.ps1'].decode('ascii') + + assert '[string] $CollectorKey = "{0}"'.format(SECRET) in script + assert '-Name "CollectorKey" -Value $CollectorKey' in script + assert 'Settings > Endpoints > Enrollment' not in script + + +def test_shared_key_off_leaves_no_key_anywhere(client, db, auth_headers, servercert): + files = _files(_build(client, auth_headers, collectorkey=SECRET, + includesharedkey=False)) + + for name, content in files.items(): + assert b'CollectorKey' not in content, name + assert SECRET.encode('ascii') not in content, name + script = files['install.ps1'].decode('ascii') + assert 'Settings > Endpoints > Enrollment' in script + assert '$sharedKeyMode = "omitted"' in script + assert json.loads(files['appsettings.json'])['apikey'] == '' + assert b'Settings > Endpoints > Enrollment' in files['README.txt'] + + +def test_shared_key_default_follows_site_setting(client, db, auth_headers, servercert): + # Setting on (default): key required. + assert _build(client, auth_headers).status_code == 400 + + Setting.set('plugin.endpoints.sharedkeyallowed', False, valuetype='boolean') + status = client.get(STATUS_URL, headers=auth_headers).get_json()['data'] + assert status['sharedkeyallowed'] is False + assert status['serverpin'] == servercert + + files = _files(_build(client, auth_headers, collectorkey=SECRET)) + assert b'CollectorKey' not in files['install.ps1'] + assert SECRET.encode('ascii') not in files['install.ps1'] + + +def test_installer_never_removes_enrolled_credential(client, db, auth_headers, servercert): + script = _files(_build(client, auth_headers, collectorkey=SECRET))[ + 'install.ps1'].decode('ascii') + assert 'existing enrolled credential found; kept as is.' in script + for line in script.splitlines(): + if 'Remove-Item' in line: + assert 'credential' not in line.lower(), line + + +def test_ca_certificate_is_not_imported_into_root(client, db, auth_headers, servercert): + 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) + 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 + + +def test_leaf_certificate_is_imported_into_root(client, db, auth_headers): + leafpem, leafpin = _make_cert(isca=False) + response = client.post( + PACKAGE_URL, + data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET, + 'cadenceseconds': '900', + 'certfile': (io.BytesIO(leafpem), 'server.crt')}, + content_type='multipart/form-data', headers=auth_headers) + files = _files(response) + script = files['install.ps1'].decode('ascii') + + assert '$trustCertInRoot = $true' in script + assert 'Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root' in script + assert json.loads(files['appsettings.json'])['serverpins'] == [leafpin] + + +def test_install_script_helper_without_pins_or_key(): + script = siteagent._install_script('https://shopdb.test', '', 900, + serverpins=[], includesharedkey=False) + assert '[string[]] $ServerPins = @()' in script + assert 'CollectorKey' not in script + assert 'ServerPins left as is' in script