Files
shopdb-flask/CERT-AUTH-IMPLEMENTATION.md
Proudlock 5cd5332397
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
Add agent fingerprinting, client certificate auth, and security audit documentation
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>
2026-09-15 09:18:12 -04:00

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?**