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):