This commit adds: - Database migrations for app_delta fingerprints and client certificates - Scripts for certificate generation and fingerprint backfilling - Comprehensive security audit reports and findings tracking - Implementation documentation for certificate authentication - Test utilities for certificate renewal and fingerprinting - GE-ENFORCE collector fix documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
696 lines
20 KiB
Markdown
696 lines
20 KiB
Markdown
# Per-Endpoint Certificate Authentication - Implementation Guide
|
|
|
|
## Overview
|
|
|
|
Implement client certificate authentication for ShopDB endpoint agents that works across multiple deployment types:
|
|
- **WJ Production:** IIS 2019 on Windows Server
|
|
- **Other Sites:** Docker Compose with Nginx reverse proxy
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Agent (has cert) → Server (IIS or Nginx) → Flask
|
|
↓
|
|
Extracts cert
|
|
Passes to Flask via header/environ
|
|
↓
|
|
Flask validates fingerprint
|
|
against database
|
|
```
|
|
|
|
## Phase 1: Database Schema
|
|
|
|
Add client certificate columns to `assets` table.
|
|
|
|
**Migration:** `plugins/endpoints/migrations/versions/0021_client_certificates.py`
|
|
|
|
```python
|
|
"""Add client certificate authentication columns
|
|
|
|
Revision ID: endpoints0021clientcerts
|
|
Revises: endpoints0020appfingerprints
|
|
Create Date: 2026-09-14
|
|
"""
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision = 'endpoints0021clientcerts'
|
|
down_revision = 'endpoints0020appfingerprints'
|
|
branch_labels = None
|
|
depends_on = None
|
|
plugin_name = 'endpoints'
|
|
|
|
|
|
def upgrade():
|
|
# Add client cert columns to assets table
|
|
op.add_column('assets',
|
|
sa.Column('client_cert_fingerprint', sa.String(64), nullable=True))
|
|
|
|
op.add_column('assets',
|
|
sa.Column('cert_issued_at', sa.DateTime, nullable=True))
|
|
|
|
op.add_column('assets',
|
|
sa.Column('cert_expires_at', sa.DateTime, nullable=True))
|
|
|
|
# Add index for fast fingerprint lookup during authentication
|
|
op.create_index(
|
|
'idx_assets_cert_fingerprint',
|
|
'assets',
|
|
['client_cert_fingerprint'],
|
|
unique=False
|
|
)
|
|
|
|
|
|
def downgrade():
|
|
op.drop_index('idx_assets_cert_fingerprint', 'assets')
|
|
op.drop_column('assets', 'cert_expires_at')
|
|
op.drop_column('assets', 'cert_issued_at')
|
|
op.drop_column('assets', 'client_cert_fingerprint')
|
|
```
|
|
|
|
**Run migration:**
|
|
```bash
|
|
docker compose exec api flask db upgrade
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Flask Code (Universal)
|
|
|
|
Add client certificate authentication support that works with both IIS and Nginx.
|
|
|
|
**File:** `shopdb/core/auth/client_cert.py` (new file)
|
|
|
|
```python
|
|
"""Client certificate authentication helpers.
|
|
|
|
Extracts client certificate fingerprints from various deployment types:
|
|
- IIS 2019: SSL_CLIENT_CERT environ (PEM format)
|
|
- IIS with ARR: X-ARR-ClientCert header (base64 DER)
|
|
- Nginx: X-Client-Cert-Fingerprint header (pre-computed)
|
|
"""
|
|
|
|
import hashlib
|
|
import base64
|
|
from flask import request, current_app
|
|
from cryptography import x509
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
|
|
def get_client_cert_fingerprint():
|
|
"""Extract client certificate SHA256 fingerprint from request.
|
|
|
|
Returns:
|
|
str: 64-character lowercase hex fingerprint, or None if no cert
|
|
|
|
Supports multiple deployment types:
|
|
- Nginx: reads X-Client-Cert-Fingerprint header (SHA256, colon-separated)
|
|
- IIS direct: reads SSL_CLIENT_CERT environ (PEM format)
|
|
- IIS with ARR: reads X-ARR-ClientCert header (base64 DER)
|
|
"""
|
|
|
|
# Option 1: Nginx passes pre-computed fingerprint
|
|
# Format: "AB:CD:EF:..." (colon-separated uppercase hex)
|
|
nginx_fp = request.headers.get('X-Client-Cert-Fingerprint')
|
|
if nginx_fp:
|
|
# Normalize: remove colons, lowercase
|
|
return nginx_fp.replace(':', '').lower()
|
|
|
|
# Option 2: IIS passes full cert in PEM format via WSGI environ
|
|
# This is the standard IIS behavior when client certs are enabled
|
|
iis_cert_pem = request.environ.get('SSL_CLIENT_CERT')
|
|
if iis_cert_pem:
|
|
try:
|
|
# Parse PEM certificate
|
|
if isinstance(iis_cert_pem, str):
|
|
iis_cert_pem = iis_cert_pem.encode('utf-8')
|
|
|
|
cert = x509.load_pem_x509_certificate(iis_cert_pem, default_backend())
|
|
|
|
# Convert to DER and hash
|
|
cert_der = cert.public_bytes(serialization.Encoding.DER)
|
|
fingerprint = hashlib.sha256(cert_der).hexdigest()
|
|
|
|
current_app.logger.debug(f'[client_cert] Extracted from IIS SSL_CLIENT_CERT: {fingerprint[:16]}...')
|
|
return fingerprint
|
|
except Exception as e:
|
|
current_app.logger.warning(f'[client_cert] Failed to parse IIS cert: {e}')
|
|
return None
|
|
|
|
# Option 3: IIS with ARR (Application Request Routing) passes base64-encoded DER
|
|
arr_cert_b64 = request.headers.get('X-ARR-ClientCert')
|
|
if arr_cert_b64:
|
|
try:
|
|
# Decode base64 to get DER bytes
|
|
cert_der = base64.b64decode(arr_cert_b64)
|
|
fingerprint = hashlib.sha256(cert_der).hexdigest()
|
|
|
|
current_app.logger.debug(f'[client_cert] Extracted from ARR header: {fingerprint[:16]}...')
|
|
return fingerprint
|
|
except Exception as e:
|
|
current_app.logger.warning(f'[client_cert] Failed to parse ARR cert: {e}')
|
|
return None
|
|
|
|
# No client certificate present
|
|
return None
|
|
|
|
|
|
def authenticate_by_client_cert():
|
|
"""Authenticate request by client certificate.
|
|
|
|
Returns:
|
|
Asset: The authenticated asset, or None if cert auth fails
|
|
|
|
Checks:
|
|
1. Client cert present in request?
|
|
2. Fingerprint matches an asset in database?
|
|
3. Certificate not expired?
|
|
|
|
Does NOT raise exceptions - returns None on any failure.
|
|
Caller decides whether to fall back to other auth methods.
|
|
"""
|
|
from shopdb.api import Asset
|
|
from datetime import datetime, timezone
|
|
|
|
fingerprint = get_client_cert_fingerprint()
|
|
if not fingerprint:
|
|
return None
|
|
|
|
# Look up asset by cert fingerprint
|
|
asset = Asset.query.filter_by(client_cert_fingerprint=fingerprint).first()
|
|
if not asset:
|
|
current_app.logger.warning(
|
|
f'[client_cert] Unknown fingerprint: {fingerprint[:16]}... '
|
|
f'(not associated with any asset)'
|
|
)
|
|
return None
|
|
|
|
# Check certificate expiration
|
|
if asset.cert_expires_at:
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
if asset.cert_expires_at < now:
|
|
current_app.logger.warning(
|
|
f'[client_cert] Expired certificate for asset {asset.assetid} '
|
|
f'(expired {asset.cert_expires_at})'
|
|
)
|
|
return None
|
|
|
|
current_app.logger.info(
|
|
f'[client_cert] Authenticated asset {asset.assetid} '
|
|
f'via certificate {fingerprint[:16]}...'
|
|
)
|
|
|
|
return asset
|
|
```
|
|
|
|
**File:** `plugins/endpoints/plugin.py` (modify `apply_collector_payload`)
|
|
|
|
```python
|
|
# Add import at top
|
|
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
|
|
|
# In apply_collector_payload method, BEFORE existing auth check:
|
|
def apply_collector_payload(self, payload: Dict) -> Dict:
|
|
"""Process endpoint collector payload (ADR-006 contract)."""
|
|
|
|
# CERT AUTH FIRST: Check for client certificate
|
|
cert_asset = authenticate_by_client_cert()
|
|
if cert_asset:
|
|
# Client cert authenticated - use this asset
|
|
assetid = cert_asset.assetid
|
|
hostname = payload.get('hostname', 'UNKNOWN')
|
|
|
|
current_app.logger.info(
|
|
f'[collector] asset_id={assetid} authenticated via client certificate'
|
|
)
|
|
|
|
# Update cert metadata if agent provides new expiration
|
|
if 'cert_expires_at' in payload:
|
|
cert_asset.cert_expires_at = payload['cert_expires_at']
|
|
db.session.flush()
|
|
else:
|
|
# FALLBACK: Existing CollectorKey authentication
|
|
# (your existing _collector_auth_or_error logic here)
|
|
pass
|
|
|
|
# Rest of method unchanged...
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: IIS 2019 Configuration (WJ Production)
|
|
|
|
### Step 1: Enable Client Certificates in IIS
|
|
|
|
1. Open **IIS Manager**
|
|
2. Select your ShopDB site (not server level)
|
|
3. Open **SSL Settings**
|
|
4. Check **"Accept"** (not "Require" - we want dual-auth during rollout)
|
|
5. Click **Apply**
|
|
|
|
### Step 2: Verify Certificate Mapping (Optional)
|
|
|
|
IIS will automatically pass client certs to WSGI via `SSL_CLIENT_CERT` environ variable - no additional config needed.
|
|
|
|
If using ARR (Application Request Routing):
|
|
1. Open **URL Rewrite** for your site
|
|
2. Add **Server Variable**: `HTTP_X_ARR_ClientCert`
|
|
3. Set value to: `{HTTP_SSL_CLIENT_CERT}`
|
|
|
|
### Step 3: Test with curl
|
|
|
|
```powershell
|
|
# Generate test client cert (PowerShell)
|
|
$cert = New-SelfSignedCertificate `
|
|
-Subject "CN=TEST-ENDPOINT" `
|
|
-CertStoreLocation "Cert:\CurrentUser\My" `
|
|
-KeyExportPolicy Exportable `
|
|
-NotAfter (Get-Date).AddYears(2)
|
|
|
|
# Export to PFX
|
|
$pwd = ConvertTo-SecureString -String "test123" -Force -AsPlainText
|
|
Export-PfxCertificate `
|
|
-Cert $cert `
|
|
-FilePath "C:\temp\test-client.pfx" `
|
|
-Password $pwd
|
|
|
|
# Test with curl (Git Bash on Windows)
|
|
curl -k --cert-type P12 --cert C:/temp/test-client.pfx:test123 \
|
|
https://localhost/api/endpoints/test/client-cert
|
|
```
|
|
|
|
**Expected response:**
|
|
```json
|
|
{
|
|
"data": {
|
|
"message": "Client cert test endpoint",
|
|
"ssl_client_cert_present": true,
|
|
"fingerprint_sha256": "325e42ef..."
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 4: Configure Agent to Use Certificate
|
|
|
|
**Agent change (C# .NET):**
|
|
|
|
```csharp
|
|
// ShopDbEndpointAgent.Core/EndpointCollector.cs
|
|
|
|
private static HttpClient CreateHttpClient(string certThumbprint = null)
|
|
{
|
|
var handler = new HttpClientHandler
|
|
{
|
|
ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) =>
|
|
{
|
|
// Accept self-signed server cert (existing behavior)
|
|
return true;
|
|
}
|
|
};
|
|
|
|
// If client cert configured, attach it
|
|
if (!string.IsNullOrEmpty(certThumbprint))
|
|
{
|
|
var cert = GetClientCertFromStore(certThumbprint);
|
|
if (cert != null)
|
|
{
|
|
handler.ClientCertificates.Add(cert);
|
|
Log.Information($"Using client certificate: {cert.Subject}");
|
|
}
|
|
}
|
|
|
|
return new HttpClient(handler)
|
|
{
|
|
Timeout = TimeSpan.FromSeconds(90)
|
|
};
|
|
}
|
|
|
|
private static X509Certificate2 GetClientCertFromStore(string thumbprint)
|
|
{
|
|
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
|
|
{
|
|
store.Open(OpenFlags.ReadOnly);
|
|
var certs = store.Certificates.Find(
|
|
X509FindType.FindByThumbprint,
|
|
thumbprint,
|
|
validOnly: false
|
|
);
|
|
return certs.Count > 0 ? certs[0] : null;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Config file:** `C:\ProgramData\ShopDB\config.json`
|
|
|
|
```json
|
|
{
|
|
"apiUrl": "https://shopdb.wj.local/api",
|
|
"clientCertThumbprint": "325E42EFA401CDA4B75BB7B478CFE01FBD5EE956C629ADD3A075A74847D71F77"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Docker + Nginx Configuration (Other Sites)
|
|
|
|
### Step 1: Add nginx.conf
|
|
|
|
**File:** `nginx.conf` (create in repo root)
|
|
|
|
```nginx
|
|
events {
|
|
worker_connections 2048;
|
|
}
|
|
|
|
http {
|
|
upstream flask_backend {
|
|
server api:5001;
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl;
|
|
server_name _;
|
|
|
|
# Server certificate (HTTPS)
|
|
ssl_certificate /etc/nginx/certs/server.crt;
|
|
ssl_certificate_key /etc/nginx/certs/server.key;
|
|
|
|
# Client certificate validation (optional during rollout)
|
|
# optional_no_ca: accept cert but don't validate against CA
|
|
# Flask validates fingerprint against database
|
|
ssl_verify_client optional_no_ca;
|
|
|
|
# Increase timeouts for long-polling agents
|
|
proxy_connect_timeout 90s;
|
|
proxy_send_timeout 90s;
|
|
proxy_read_timeout 90s;
|
|
|
|
# Health check endpoint
|
|
location /api/dashboard/health {
|
|
proxy_pass http://flask_backend;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Connection "";
|
|
}
|
|
|
|
# All other routes
|
|
location / {
|
|
proxy_pass http://flask_backend;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Connection "";
|
|
|
|
# Pass real client IP (not nginx IP)
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Host $host;
|
|
|
|
# Pass client certificate fingerprint to Flask
|
|
# $ssl_client_fingerprint format: "AB:CD:EF:..." (colon-separated hex)
|
|
proxy_set_header X-Client-Cert-Fingerprint $ssl_client_fingerprint;
|
|
proxy_set_header X-Client-Cert-S-DN $ssl_client_s_dn;
|
|
}
|
|
|
|
# Access log includes client cert status
|
|
access_log /dev/stdout combined;
|
|
error_log /dev/stderr warn;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 2: Update docker-compose.yml
|
|
|
|
```yaml
|
|
services:
|
|
nginx:
|
|
image: nginx:1.27-alpine
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- api
|
|
ports:
|
|
- "5001:443"
|
|
volumes:
|
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
- ./certs:/etc/nginx/certs:ro
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--spider", "-q", "--no-check-certificate", "https://localhost/api/dashboard/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
|
|
api:
|
|
# ... existing config ...
|
|
ports:
|
|
# Expose on localhost only (for debugging)
|
|
- "127.0.0.1:5002:5001"
|
|
command:
|
|
- gunicorn
|
|
- --bind
|
|
- 0.0.0.0:5001
|
|
# REMOVE: --certfile (nginx does TLS now)
|
|
# REMOVE: --keyfile
|
|
# REMOVE: --cert-reqs
|
|
- --worker-class
|
|
- gevent
|
|
- --workers
|
|
- "4"
|
|
- --worker-connections
|
|
- "2000"
|
|
- --timeout
|
|
- "60"
|
|
- --access-logfile
|
|
- "-"
|
|
- --access-logformat
|
|
- '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)sus "%(f)s" "%(a)s"'
|
|
- --error-logfile
|
|
- "-"
|
|
- wsgi:app
|
|
# Remove healthcheck (nginx handles it now)
|
|
```
|
|
|
|
### Step 3: Rebuild and Deploy
|
|
|
|
```bash
|
|
# Stop current stack
|
|
docker compose down
|
|
|
|
# Start with nginx
|
|
docker compose up -d
|
|
|
|
# Verify nginx is proxying
|
|
curl -k https://localhost:5001/api/dashboard/health
|
|
|
|
# Test with client cert (same cert from earlier test)
|
|
openssl s_client -connect localhost:5001 \
|
|
-cert certs/test-client.crt \
|
|
-key certs/test-client.key \
|
|
-quiet <<< "GET /api/endpoints/test/client-cert HTTP/1.1
|
|
Host: localhost:5001
|
|
Connection: close
|
|
|
|
"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Agent Certificate Generation
|
|
|
|
### PowerShell Script (Windows agents)
|
|
|
|
**File:** `scripts/generate-agent-cert.ps1`
|
|
|
|
```powershell
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[string]$Hostname,
|
|
|
|
[Parameter(Mandatory=$false)]
|
|
[int]$ValidityYears = 2
|
|
)
|
|
|
|
# Generate self-signed cert for this endpoint
|
|
$cert = New-SelfSignedCertificate `
|
|
-Subject "CN=$Hostname" `
|
|
-DnsName $Hostname `
|
|
-CertStoreLocation "Cert:\LocalMachine\My" `
|
|
-KeyExportPolicy NonExportable `
|
|
-KeyUsage DigitalSignature, KeyEncipherment `
|
|
-NotAfter (Get-Date).AddYears($ValidityYears) `
|
|
-HashAlgorithm SHA256
|
|
|
|
Write-Host "Generated certificate:"
|
|
Write-Host " Thumbprint: $($cert.Thumbprint)"
|
|
Write-Host " Subject: $($cert.Subject)"
|
|
Write-Host " Expires: $($cert.NotAfter)"
|
|
|
|
# Compute SHA256 fingerprint (for database registration)
|
|
$certBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
|
|
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
|
$fingerprintBytes = $sha256.ComputeHash($certBytes)
|
|
$fingerprint = ($fingerprintBytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
|
|
|
Write-Host ""
|
|
Write-Host "Register in ShopDB with:"
|
|
Write-Host " UPDATE assets"
|
|
Write-Host " SET client_cert_fingerprint = '$fingerprint',"
|
|
Write-Host " cert_issued_at = CURRENT_TIMESTAMP,"
|
|
Write-Host " cert_expires_at = '$($cert.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"))'"
|
|
Write-Host " WHERE hostname = '$Hostname';"
|
|
|
|
# Update agent config
|
|
$configPath = "C:\ProgramData\ShopDB\config.json"
|
|
if (Test-Path $configPath) {
|
|
$config = Get-Content $configPath | ConvertFrom-Json
|
|
$config.clientCertThumbprint = $cert.Thumbprint
|
|
$config | ConvertTo-Json | Set-Content $configPath
|
|
Write-Host ""
|
|
Write-Host "Updated $configPath with thumbprint"
|
|
}
|
|
|
|
return $cert.Thumbprint
|
|
```
|
|
|
|
**Usage:**
|
|
```powershell
|
|
# On endpoint VM
|
|
.\generate-agent-cert.ps1 -Hostname "DESKTOP-HF1K9M4"
|
|
|
|
# Output:
|
|
# Generated certificate:
|
|
# Thumbprint: 325E42EFA401CDA4B75BB7B478CFE01FBD5EE956C629ADD3A075A74847D71F77
|
|
# Subject: CN=DESKTOP-HF1K9M4
|
|
# Expires: 9/14/2028 12:00:00 PM
|
|
#
|
|
# Register in ShopDB with:
|
|
# UPDATE assets
|
|
# SET client_cert_fingerprint = '325e42efa401cda4b75bb7b478cfe01fbd5ee956c629add3a075a74847d71f77',
|
|
# cert_issued_at = CURRENT_TIMESTAMP,
|
|
# cert_expires_at = '2028-09-14 12:00:00'
|
|
# WHERE hostname = 'DESKTOP-HF1K9M4';
|
|
```
|
|
|
|
---
|
|
|
|
## Rollout Sequence
|
|
|
|
### Week 1: WJ Production (IIS)
|
|
1. Deploy Flask code (cert auth + CollectorKey fallback)
|
|
2. Configure IIS SSL Settings → "Accept" client certs
|
|
3. Generate cert for 1 test endpoint
|
|
4. Register cert fingerprint in database
|
|
5. Update agent config with thumbprint
|
|
6. Verify dual-auth works (cert + fallback)
|
|
|
|
### Week 2: Remaining WJ Fleet
|
|
1. Generate certs for remaining endpoints (PowerShell script)
|
|
2. Register fingerprints in database
|
|
3. Deploy updated agent configs
|
|
|
|
### Week 3: Docker Sites
|
|
1. Add nginx container to docker-compose.yml
|
|
2. Deploy updated bundle to 1 pilot site
|
|
3. Generate certs for that site's endpoints
|
|
4. Register fingerprints
|
|
5. Verify nginx proxy works
|
|
|
|
### Month 2: Remaining Sites
|
|
1. Roll out nginx updates to remaining Docker sites
|
|
2. Generate certs per-site
|
|
3. After 100% cert adoption: change `ssl_verify_client optional` to `require`
|
|
|
|
---
|
|
|
|
## Testing Checklist
|
|
|
|
### Flask Tests
|
|
- [ ] `get_client_cert_fingerprint()` returns None when no cert
|
|
- [ ] `get_client_cert_fingerprint()` parses nginx header (X-Client-Cert-Fingerprint)
|
|
- [ ] `get_client_cert_fingerprint()` parses IIS environ (SSL_CLIENT_CERT)
|
|
- [ ] `authenticate_by_client_cert()` returns asset when fingerprint matches
|
|
- [ ] `authenticate_by_client_cert()` returns None when cert expired
|
|
- [ ] Collector falls back to CollectorKey when cert auth fails
|
|
|
|
### IIS Tests
|
|
- [ ] IIS passes `SSL_CLIENT_CERT` to Flask
|
|
- [ ] Agent with cert authenticates successfully
|
|
- [ ] Agent without cert falls back to CollectorKey
|
|
- [ ] Expired cert rejected
|
|
|
|
### Docker/Nginx Tests
|
|
- [ ] Nginx forwards requests to Gunicorn
|
|
- [ ] Nginx adds `X-Client-Cert-Fingerprint` header
|
|
- [ ] Agent with cert authenticates via nginx
|
|
- [ ] Agent without cert uses CollectorKey
|
|
- [ ] Long-polling works (25+ second holds)
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
1. **Cert Storage:** LocalMachine\My store (admin-only access)
|
|
2. **Private Key:** Non-exportable (can't be stolen)
|
|
3. **Expiration:** 2 years (balance security vs. rotation overhead)
|
|
4. **Revocation:** Remove fingerprint from database (immediate)
|
|
5. **Self-Signed:** No CA needed (air-gapped sites)
|
|
|
|
---
|
|
|
|
## Rollback Plan
|
|
|
|
### If Flask code breaks:
|
|
```bash
|
|
git revert <commit>
|
|
docker compose restart api # or iisreset
|
|
```
|
|
|
|
### If IIS config breaks:
|
|
1. IIS Manager → SSL Settings → Uncheck "Accept"
|
|
2. Click Apply
|
|
3. Agents fall back to CollectorKey immediately
|
|
|
|
### If nginx breaks:
|
|
```bash
|
|
# Restore direct Gunicorn access
|
|
docker compose down nginx
|
|
# Re-add --certfile/--keyfile to api service
|
|
docker compose up -d api
|
|
```
|
|
|
|
---
|
|
|
|
## Monitoring
|
|
|
|
### Metrics to Track
|
|
- % of agents using cert auth vs. CollectorKey
|
|
- Cert expiration warnings (30 days before)
|
|
- Failed cert authentications (unknown fingerprint)
|
|
- Expired cert attempts
|
|
|
|
### Logs to Watch
|
|
```
|
|
[client_cert] Authenticated asset 2528 via certificate 325e42ef...
|
|
[client_cert] Unknown fingerprint: 325e42ef... (not associated with any asset)
|
|
[client_cert] Expired certificate for asset 2528 (expired 2026-09-14 12:00:00)
|
|
```
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
1. **Create migration** (0021_client_certificates.py)
|
|
2. **Add Flask auth code** (client_cert.py)
|
|
3. **Test on IIS** (your WJ production)
|
|
4. **Add nginx config** (for Docker sites)
|
|
5. **Generate first cert** (test endpoint)
|
|
6. **Document in wiki** (deployment runbook)
|
|
|
|
**Estimated effort:**
|
|
- Flask code: 2 hours
|
|
- IIS config + test: 1 hour
|
|
- Docker/nginx: 1 hour
|
|
- Agent changes: 2 hours
|
|
- **Total: 6 hours**
|
|
|
|
**Ready to start with Flask code?**
|