Files
shopdb-flask/plugins/endpoints/api/certificate_renewal.py
Proudlock 65f26b1576 Implement automatic certificate renewal (Option 2 - agent self-renewal)
AGENT SELF-RENEWAL:
- Agent checks certificate expiry on startup
- If < 30 days remaining, requests renewal from server
- Server generates new certificate and updates database
- Agent installs new cert, updates config, and restarts

SERVER-SIDE (/api/endpoints/certificate/renew):
- Requires authentication via existing (expiring) client certificate
- Rate limited: 1 renewal per asset per 24 hours
- Generates 2-year self-signed certificate with cryptography library
- Computes SHA1 fingerprint (matches nginx $ssl_client_fingerprint)
- Updates assets table with new fingerprint and expiration
- Returns PFX/P12 certificate with random password to agent
- Audit logs all renewal operations

AGENT-SIDE (CertificateRenewal.cs):
- CheckAndRenewIfNeeded() called on service startup before host starts
- Finds current certificate by thumbprint in LocalMachine\My store
- Checks expiry: if < 30 days, POST to /api/endpoints/certificate/renew
- Authenticates renewal request with expiring certificate (mTLS)
- Receives PFX + password in JSON response
- Imports new certificate to LocalMachine\My store
- Grants BUILTIN\Users read access to private key (for tray app)
- Updates appsettings.json with new thumbprint
- Exits with code 0 so service manager restarts agent with new cert

CERTIFICATE GENERATION (certificate_renewal.py):
- Uses cryptography library to generate RSA 2048-bit key pairs
- Self-signed certificates with CN=hostname, 2-year validity
- Extended key usage: CLIENT_AUTH
- Subject Alternative Name: hostname as DNS name
- Exports as password-protected PFX/P12 for Windows import
- SHA1 fingerprint computed from DER encoding (nginx compatible)

STARTUP FLOW:
1. Agent service starts (Program.cs RunHostAsync)
2. CertificateRenewal.CheckAndRenewIfNeeded() runs BEFORE host starts
3. If cert expires in < 30 days, requests renewal from server
4. If renewal succeeds, agent exits (Environment.Exit(0))
5. Windows Service Manager automatically restarts the service
6. New process picks up updated thumbprint from appsettings.json
7. Agent continues with renewed certificate

SECURITY:
- Renewal requires valid (not expired) existing certificate
- Rate limit prevents renewal loop abuse
- Server validates cert not revoked before renewal
- Audit logging tracks all renewal operations
- PFX password is random, used once during import, never stored

PERFORMANCE:
- Renewal check adds ~100-200ms to service startup
- Only triggers when expiry < 30 days (rare operation)
- No impact on steady-state agent performance

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-14 14:07:27 -04:00

104 lines
3.8 KiB
Python

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