diff --git a/agent/ShopDbEndpointAgent.Core/CertificateRenewal.cs b/agent/ShopDbEndpointAgent.Core/CertificateRenewal.cs new file mode 100644 index 00000000..100c170c --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CertificateRenewal.cs @@ -0,0 +1,297 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +/// +/// Automatic client certificate renewal when expiring within 30 days. +/// Agent checks cert expiry on startup, requests renewal from server, installs +/// new cert, updates config, and schedules service restart. +/// +public class CertificateRenewal +{ + private readonly ILogger logger; + private readonly AgentConfig config; + + private const int RENEWAL_THRESHOLD_DAYS = 30; + private const string RENEWAL_ENDPOINT = "/api/endpoints/certificate/renew"; + + public CertificateRenewal(ILogger logger, AgentConfig config) + { + this.logger = logger; + this.config = config; + } + + /// + /// Check if client certificate needs renewal (expires in < 30 days). + /// Called on agent startup before first telemetry report. + /// + public async Task CheckAndRenewIfNeeded() + { + if (string.IsNullOrWhiteSpace(config.ClientCertThumbprint)) + { + // No cert configured, skip renewal check + return false; + } + + // Find current certificate + var cert = FindClientCertificate(config.ClientCertThumbprint); + if (cert == null) + { + logger.LogWarning( + "Certificate {Thumbprint} not found in LocalMachine\\My store. " + + "Cannot check expiry or renew.", + config.ClientCertThumbprint); + return false; + } + + // Check expiry + var daysRemaining = (cert.NotAfter - DateTime.Now).TotalDays; + logger.LogInformation( + "Client certificate expires {ExpiryDate} ({DaysRemaining:F1} days remaining)", + cert.NotAfter, daysRemaining); + + if (daysRemaining > RENEWAL_THRESHOLD_DAYS) + { + // Certificate still valid for > 30 days, no renewal needed + return false; + } + + // Certificate expiring soon or already expired, request renewal + logger.LogWarning( + "Certificate expires in {DaysRemaining:F1} days (threshold {Threshold} days). " + + "Requesting renewal from server...", + daysRemaining, RENEWAL_THRESHOLD_DAYS); + + try + { + await RenewCertificate(cert); + return true; // Renewal succeeded, service should restart + } + catch (Exception ex) + { + logger.LogError(ex, + "Certificate renewal failed. Agent will continue with existing cert " + + "until next startup or manual intervention."); + return false; + } + } + + private X509Certificate2? FindClientCertificate(string thumbprint) + { + using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadOnly); + + // Thumbprint lookup is case-insensitive, no separators + var normalizedThumbprint = thumbprint.Replace(":", "").Replace(" ", ""); + var certs = store.Certificates.Find( + X509FindType.FindByThumbprint, + normalizedThumbprint, + validOnly: false); // Include expired certs for renewal + + return certs.Count > 0 ? certs[0] : null; + } + + private async Task RenewCertificate(X509Certificate2 oldCert) + { + // Step 1: Request new certificate from server + var response = await RequestCertificateFromServer(oldCert); + if (response == null) + { + throw new InvalidOperationException("Server returned null renewal response"); + } + + // Step 2: Import certificate to LocalMachine\My store + var newCert = ImportPfxCertificate(response.Pfx, response.Password); + + // Step 3: Update appsettings.json with new thumbprint + UpdateConfigFile(response.Thumbprint); + + // Step 4: Grant Users group access to private key (for tray app) + GrantPrivateKeyAccess(newCert); + + logger.LogInformation( + "Certificate renewal complete. New cert thumbprint: {Thumbprint}, " + + "expires: {ExpiryDate}. Service restart required.", + response.Thumbprint, response.ExpiresAt); + } + + private async Task RequestCertificateFromServer(X509Certificate2 currentCert) + { + var handler = new HttpClientHandler(); + + // Attach current (expiring) certificate to authenticate renewal request + handler.ClientCertificates.Add(currentCert); + + // Accept server cert (TODO: validate via ServerCertThumbprint if configured) + handler.ServerCertificateCustomValidationCallback = (sender, cert, chain, errors) => + { + if (cert == null) return false; + if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint)) + { + var actualThumbprint = cert.GetCertHashString(); + var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", ""); + if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase)) + { + logger.LogError( + "Server certificate thumbprint mismatch during renewal. " + + "Expected {Expected}, got {Actual}", + expectedThumbprint, actualThumbprint); + return false; + } + } + return true; + }; + + using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + var url = config.BaseUrl.TrimEnd('/') + RENEWAL_ENDPOINT; + + logger.LogInformation("Requesting certificate renewal from {Url}", url); + + var httpResponse = await http.PostAsync(url, new StringContent("", Encoding.UTF8, "application/json")); + if (!httpResponse.IsSuccessStatusCode) + { + var errorBody = await httpResponse.Content.ReadAsStringAsync(); + throw new HttpRequestException( + $"Certificate renewal request failed: {httpResponse.StatusCode} - {errorBody}"); + } + + var json = await httpResponse.Content.ReadAsStringAsync(); + var envelope = JsonSerializer.Deserialize(json); + if (envelope?.Data == null) + { + throw new InvalidOperationException("Server returned invalid renewal response"); + } + + return envelope.Data; + } + + private X509Certificate2 ImportPfxCertificate(string pfxBase64, string password) + { + var pfxBytes = Convert.FromBase64String(pfxBase64); + + // Import to LocalMachine\My store + var cert = new X509Certificate2(pfxBytes, password, + X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); + + using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadWrite); + store.Add(cert); + + logger.LogInformation( + "Imported new certificate to LocalMachine\\My: {Subject}, thumbprint: {Thumbprint}", + cert.Subject, cert.Thumbprint); + + return cert; + } + + private void UpdateConfigFile(string newThumbprint) + { + // appsettings.json location: same directory as executable + var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(configPath)) + { + throw new FileNotFoundException($"Configuration file not found: {configPath}"); + } + + // Read, parse, update, write back + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Name.Equals("clientcertthumbprint", StringComparison.OrdinalIgnoreCase)) + { + writer.WriteString(prop.Name, newThumbprint); + } + else + { + prop.WriteTo(writer); + } + } + writer.WriteEndObject(); + writer.Flush(); + + var updatedJson = Encoding.UTF8.GetString(stream.ToArray()); + File.WriteAllText(configPath, updatedJson); + + logger.LogInformation("Updated {ConfigPath} with new certificate thumbprint", configPath); + } + + private void GrantPrivateKeyAccess(X509Certificate2 cert) + { + // Grant BUILTIN\Users read access to private key so tray app can use it + try + { + var rsaKey = cert.GetRSAPrivateKey(); + if (rsaKey == null) + { + logger.LogWarning("Certificate has no RSA private key, cannot set permissions"); + return; + } + + // CNG key: use key name to find file + if (rsaKey is System.Security.Cryptography.RSACng cngKey) + { + var keyPath = cngKey.Key.UniqueName; + var searchPaths = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + @"Microsoft\Crypto\SystemKeys"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + @"Microsoft\Crypto\Keys"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + @"Microsoft\Crypto\RSA\MachineKeys") + }; + + foreach (var searchPath in searchPaths) + { + var keyFile = Path.Combine(searchPath, keyPath); + if (File.Exists(keyFile)) + { + var acl = File.GetAccessControl(keyFile); + var rule = new System.Security.AccessControl.FileSystemAccessRule( + "BUILTIN\\Users", + System.Security.AccessControl.FileSystemRights.Read | + System.Security.AccessControl.FileSystemRights.ReadAndExecute, + System.Security.AccessControl.AccessControlType.Allow); + acl.AddAccessRule(rule); + File.SetAccessControl(keyFile, acl); + + logger.LogInformation("Granted Users group access to private key at {KeyFile}", keyFile); + return; + } + } + + logger.LogWarning("Private key file not found in any search path"); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not set private key permissions (tray app may not work)"); + } + } + + // JSON response schema + private class RenewalResponseEnvelope + { + public RenewalResponse? Data { get; set; } + } + + private class RenewalResponse + { + public string Pfx { get; set; } = ""; + public string Password { get; set; } = ""; + public string Thumbprint { get; set; } = ""; + public DateTime ExpiresAt { get; set; } + } +} diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index 1e041166..19735c72 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -172,6 +172,35 @@ catch (Exception error) static async Task RunHostAsync(AgentConfig config, string agentVersion, string logDirectory, bool forceConsole) { + // Certificate renewal check: if client cert expires within 30 days, request + // renewal from server, install new cert, and exit so service manager restarts us. + // This runs BEFORE the host starts so a renewed cert is picked up immediately. + var renewalLogger = LoggerFactory.Create(logging => + ConfigureLogging(logging, logDirectory)).CreateLogger(); + var renewal = new CertificateRenewal(renewalLogger, config); + + try + { + var didRenew = await renewal.CheckAndRenewIfNeeded(); + if (didRenew) + { + renewalLogger.LogInformation( + "Certificate renewed successfully. Exiting so service manager restarts " + + "the agent with new certificate."); + // Exit code 0 so service manager sees this as normal termination and + // restarts the service (if configured as Automatic). The new process + // will pick up the updated thumbprint from appsettings.json. + Environment.Exit(0); + } + } + catch (Exception ex) + { + renewalLogger.LogError(ex, + "Certificate renewal check failed. Starting agent with existing certificate."); + // Continue startup with existing (possibly expiring) certificate rather + // than blocking service start. Next startup will retry renewal. + } + var builder = Host.CreateApplicationBuilder(); if (!forceConsole) diff --git a/plugins/endpoints/api/certificate_renewal.py b/plugins/endpoints/api/certificate_renewal.py new file mode 100644 index 00000000..218dcd61 --- /dev/null +++ b/plugins/endpoints/api/certificate_renewal.py @@ -0,0 +1,103 @@ +"""Server-side certificate generation for agent self-renewal. + +Agents check certificate expiry on startup and request renewal when < 30 days +remaining. Server generates new self-signed certificate, computes fingerprint, +updates database, and returns certificate as password-protected PFX to agent. +""" + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from flask import current_app + +from cryptography import x509 +from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend + + +def generate_client_certificate(hostname, validity_days=730): + """Generate self-signed client certificate for endpoint agent. + + Args: + hostname: Computer hostname (used as CN) + validity_days: Certificate validity period (default 730 = 2 years) + + Returns: + tuple: (pfx_bytes, pfx_password, sha1_fingerprint, expires_at) + - pfx_bytes: Password-protected PFX/P12 certificate with private key + - pfx_password: Random password for PFX (agent discards after import) + - sha1_fingerprint: 40-character lowercase hex SHA1 fingerprint + - expires_at: Certificate expiration datetime (UTC) + """ + # Generate RSA private key (2048-bit for compatibility with Windows) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + + # Certificate subject and issuer (self-signed, so both are the same) + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'ShopDB Endpoint Fleet'), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, 'Endpoint Agents'), + ]) + + # Certificate validity period + now = datetime.now(timezone.utc) + not_valid_before = now + not_valid_after = now + timedelta(days=validity_days) + + # Build certificate + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + not_valid_before + ).not_valid_after( + not_valid_after + ).add_extension( + # Client authentication extended key usage + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=True, + ).add_extension( + # Subject Alternative Name (hostname as DNS name) + x509.SubjectAlternativeName([x509.DNSName(hostname)]), + critical=False, + ).add_extension( + # Basic constraints: not a CA + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ).sign(private_key, hashes.SHA256(), default_backend()) + + # Compute SHA1 fingerprint (matches nginx $ssl_client_fingerprint) + cert_der = cert.public_bytes(serialization.Encoding.DER) + sha1_hash = hashlib.sha1(cert_der).hexdigest() + + # Generate random PFX password (agent uses once during import, then discards) + pfx_password = secrets.token_urlsafe(32) + + # Serialize to PFX/P12 format with password protection + # Windows Certificate Store imports PFX format + pfx_bytes = serialization.pkcs12.serialize_key_and_certificates( + name=hostname.encode('utf-8'), + key=private_key, + cert=cert, + cas=None, # No CA chain for self-signed + encryption_algorithm=serialization.BestAvailableEncryption( + pfx_password.encode('utf-8') + ) + ) + + current_app.logger.info( + f'[cert_renewal] Generated certificate for {hostname}: ' + f'fingerprint={sha1_hash[:16]}..., expires={not_valid_after}') + + return (pfx_bytes, pfx_password, sha1_hash, not_valid_after.replace(tzinfo=None)) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 27c08422..1fd8a1c8 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -3445,6 +3445,122 @@ def download_app_package(appversionid: int): return send_upload(package_dir, filename, as_attachment=True) +@endpoints_bp.route('/certificate/renew', methods=['POST']) +def renew_client_certificate(): + """Agent self-renewal: generate new cert when expiring < 30 days. + + Requires authentication via EXISTING client certificate. Agent checks cert + expiry on startup, POSTs to this endpoint when < 30 days remaining, receives + new certificate as password-protected PFX, installs it, updates config, and + restarts. + + Request: Empty body (agent identity from cert authentication) + Response: { + "pfx": "base64-encoded PFX/P12 certificate with private key", + "password": "random password for PFX import (use once, discard)", + "thumbprint": "new certificate thumbprint (uppercase, no colons)", + "expires_at": "2028-09-14T12:34:56" (ISO format, UTC) + } + + Security: + - MUST authenticate via valid client certificate (not expired) + - Rate limited: 1 renewal per asset per 24 hours + - Logs all renewal operations to AuditLog + - New cert valid for 2 years from generation time + """ + from flask import g + import base64 + + # Require client certificate authentication + # (Cannot renew without proving you hold current cert) + if not hasattr(g, 'cert_authenticated_asset_id'): + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Certificate renewal requires client certificate authentication', + http_code=401) + + asset_id = g.cert_authenticated_asset_id + asset = db.session.get(Asset, asset_id) + if not asset: + return error_response(ErrorCodes.NOT_FOUND, + f'Asset {asset_id} not found', http_code=404) + + # Get hostname from computers plugin + from ..models import EndpointState + state = EndpointState.query.filter_by(assetid=asset_id).first() + if not state or not state.hostname: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Cannot renew certificate: hostname not known', + http_code=400) + + hostname = state.hostname + + # Rate limit: Only allow renewal once per 24 hours per asset + # (Prevents abuse if agent gets stuck in renewal loop) + if asset.cert_issued_at: + hours_since_issue = (datetime.now(timezone.utc).replace(tzinfo=None) - + asset.cert_issued_at).total_seconds() / 3600 + if hours_since_issue < 24: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Certificate was issued {hours_since_issue:.1f} hours ago. ' + f'Renewal only allowed after 24 hours.', + http_code=429) + + # Generate new certificate + from .certificate_renewal import generate_client_certificate + try: + pfx_bytes, pfx_password, fingerprint, expires_at = \ + generate_client_certificate(hostname, validity_days=730) + except Exception as e: + current_app.logger.error( + f'[cert_renewal] Failed to generate certificate for asset {asset_id}: {e}') + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Certificate generation failed', + http_code=500) + + # Update database with new certificate fingerprint and expiration + old_fingerprint = asset.client_cert_fingerprint + asset.client_cert_fingerprint = fingerprint + asset.cert_issued_at = datetime.now(timezone.utc).replace(tzinfo=None) + asset.cert_expires_at = expires_at + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + current_app.logger.error( + f'[cert_renewal] Failed to update database for asset {asset_id}: {e}') + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'Database update failed', + http_code=500) + + # Audit log + AuditLog.log( + action='endpoints.cert.renewed', + userid=None, # Agent action, not user action + entityid=asset_id, + entityname=asset.assetnumber, + details=f'Certificate renewed: old={old_fingerprint[:16] if old_fingerprint else "none"}..., ' + f'new={fingerprint[:16]}..., expires={expires_at}') + + current_app.logger.info( + f'[cert_renewal] Asset {asset_id} ({asset.assetnumber}) renewed certificate: ' + f'{fingerprint[:16]}... expires {expires_at}') + + # Return certificate to agent + # Thumbprint uppercase with no separators (matches Windows cert store format) + return success_response({ + 'pfx': base64.b64encode(pfx_bytes).decode('ascii'), + 'password': pfx_password, + 'thumbprint': fingerprint.upper(), + 'expires_at': expires_at.isoformat() + }) + + @endpoints_bp.route('/apppackage/app/', methods=['GET']) @endpoints_bp.route('/apppackage/app//', methods=['GET']) def download_app_package_by_app(app_id: int, filename: str = None):