Fix critical security vulnerabilities in client certificate authentication
CRITICAL FIXES: - Enforce cert authentication on collector telemetry endpoint - Add authorization check in report_command_result (prefer cert over hostname) - Implement server certificate pinning in C# agents (optional) - Add constant-time fingerprint comparison to prevent timing attacks - Validate fingerprint format (40 or 64 hex chars only) AUTHENTICATION: - Created shopdb/core/auth/client_cert.py with authenticate_by_client_cert() - Extracts fingerprint from nginx header (SHA1), IIS environ, or ARR header - Validates against assets.client_cert_fingerprint in database - Checks certificate expiration (cert_expires_at column) - Falls back to API key when cert not present (dual-auth during rollout) AUTHORIZATION: - shopdb/core/api/collector.py: Added cert auth to generic_collect() before API key check. Sets g.cert_authenticated_asset_id when cert validates. - plugins/endpoints/plugin.py: apply_collector_payload() compares g.cert_authenticated_asset_id with payload's resolved assetid. Raises PermissionError if mismatch (prevents agent A from submitting data for asset B by changing hostname field). - plugins/endpoints/api/routes.py: report_command_result() prefers g.cert_authenticated_asset_id over hostname parameter. Requires either cert or hostname for authorization. AGENT CHANGES: - AgentConfig.cs: Added ServerCertThumbprint config for certificate pinning - CollectorClient.cs: Validates server cert thumbprint when configured - CommandClient.cs: Validates server cert on WebSocket TLS - FleetAlertClient.cs: Validates server cert on WebSocket TLS - When ServerCertThumbprint is empty, accepts all certs (rollout compat) REVOCATION SUPPORT: - Added cert_revoked_at and cert_revocation_reason columns to assets table - Migration 0022_client_cert_revocation.py adds columns - Revocation check uses hasattr() for backward compatibility - Revocation requires manual database UPDATE (instant kill switch) SECURITY HARDENING: - Constant-time fingerprint comparison via hmac.compare_digest() - Format validation: must be 40 (SHA1) or 64 (SHA256) hex chars - SHA1 documented as nginx limitation, acceptable for air-gapped self-signed - Audit logging for failed authentication attempts (AuditLog.log) DEPLOYMENT: - Dual-auth during rollout: cert OR API key both work - Agents without certs continue using API key (backward compatible) - No impact on existing fleet until certs provisioned - Rollout: server first, pilot certs, fleet rollout, retire API key Addresses Fable security review findings: 1 critical + 5 high vulnerabilities related to authentication bypass, authorization flaws, and timing attacks. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,19 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
|
||||
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
|
||||
|
||||
// Optional client certificate thumbprint for mTLS authentication. When set,
|
||||
// the agent attaches the certificate from LocalMachine\My to HTTPS requests.
|
||||
// The server validates the fingerprint against its database. Empty = use
|
||||
// X-API-Key only (dual-auth during rollout).
|
||||
[JsonPropertyName("clientcertthumbprint")] public string? ClientCertThumbprint { get; set; }
|
||||
|
||||
// Optional server certificate thumbprint for certificate pinning. When set,
|
||||
// the agent validates that the server's TLS certificate matches this exact
|
||||
// thumbprint (SHA-1, hex, no separators). Blocks man-in-the-middle attacks
|
||||
// in air-gapped environments with self-signed certificates. Empty = accept
|
||||
// all server certificates (insecure, existing behavior for rollout).
|
||||
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
|
||||
|
||||
// Jitter spreads a fleet's reports so they do not arrive in lockstep
|
||||
// (thundering herd). Actual delay = cadence +/- up to jitterseconds.
|
||||
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -32,7 +33,10 @@ public sealed class CollectorClient : IDisposable
|
||||
{
|
||||
this.logger = logger;
|
||||
this.maxAttempts = Math.Max(1, maxAttempts);
|
||||
http = new HttpClient
|
||||
|
||||
// Create HttpClient with optional client certificate for mTLS
|
||||
var handler = CreateHttpClientHandler(config);
|
||||
http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
@@ -40,6 +44,104 @@ public sealed class CollectorClient : IDisposable
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
// Creates HttpClientHandler with optional client certificate for mTLS and
|
||||
// optional server certificate pinning. If ClientCertThumbprint is configured,
|
||||
// attaches cert from LocalMachine\My. If ServerCertThumbprint is configured,
|
||||
// validates the server's certificate matches exactly (certificate pinning).
|
||||
private HttpClientHandler CreateHttpClientHandler(AgentConfig config)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
// Server certificate validation: pinning when configured, otherwise accept all
|
||||
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
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. Expected {Expected}, got {Actual}",
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug("Server certificate validated via pinning: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
};
|
||||
|
||||
// Attach client certificate if configured
|
||||
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
|
||||
if (clientCert != null)
|
||||
{
|
||||
handler.ClientCertificates.Add(clientCert);
|
||||
logger.LogInformation(
|
||||
"Attached client certificate: {Subject} (expires {Expires})",
|
||||
clientCert.Subject, clientCert.NotAfter);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"ClientCertThumbprint configured ({Thumbprint}) but certificate not found in LocalMachine\\My",
|
||||
config.ClientCertThumbprint);
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
|
||||
// Returns null if not found. The certificate must have a private key.
|
||||
private X509Certificate2? FindClientCertificate(string thumbprint)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
thumbprint.Trim(),
|
||||
validOnly: false);
|
||||
|
||||
if (certs.Count == 0)
|
||||
return null;
|
||||
|
||||
var cert = certs[0];
|
||||
if (!cert.HasPrivateKey)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Found certificate {Subject} but it has no private key",
|
||||
cert.Subject);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cert;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Failed to load client certificate with thumbprint {Thumbprint}",
|
||||
thumbprint);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CollectorResponse> SendAsync(
|
||||
EndpointReport report, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -40,6 +41,7 @@ public sealed class CommandClient : IDisposable
|
||||
{
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
SslOptions = CreateSslOptions(config),
|
||||
};
|
||||
http = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
@@ -51,6 +53,85 @@ public sealed class CommandClient : IDisposable
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
// Creates SslClientAuthenticationOptions with optional client certificate and
|
||||
// optional server certificate pinning. If ServerCertThumbprint is configured,
|
||||
// validates the server's certificate matches exactly (certificate pinning).
|
||||
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config)
|
||||
{
|
||||
var options = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
{
|
||||
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
|
||||
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
|
||||
var actualThumbprint = x509Cert.GetCertHashString();
|
||||
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
|
||||
|
||||
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError(
|
||||
"Server certificate thumbprint mismatch on command channel. Expected {Expected}, got {Actual}",
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug("Server certificate validated via pinning on command channel: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
|
||||
if (clientCert != null)
|
||||
{
|
||||
options.ClientCertificates = new X509CertificateCollection { clientCert };
|
||||
logger.LogInformation(
|
||||
"Attached client certificate to command channel: {Subject}",
|
||||
clientCert.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
|
||||
private X509Certificate2? FindClientCertificate(string thumbprint)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
thumbprint.Trim(),
|
||||
validOnly: false);
|
||||
|
||||
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Failed to load client certificate with thumbprint {Thumbprint}",
|
||||
thumbprint);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Claim the next pending command for this hostname, or null when the server
|
||||
// returned 204 (nothing queued within the hold window). A 4xx other than 429
|
||||
// is surfaced so the worker can back off instead of hot-looping a bad key or
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -30,6 +31,7 @@ public sealed class FleetAlertClient : IDisposable
|
||||
{
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
SslOptions = CreateSslOptions(config),
|
||||
};
|
||||
http = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
@@ -39,6 +41,82 @@ public sealed class FleetAlertClient : IDisposable
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
// Creates SslClientAuthenticationOptions with optional client certificate and
|
||||
// optional server certificate pinning. If ServerCertThumbprint is configured,
|
||||
// validates the server's certificate matches exactly (certificate pinning).
|
||||
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config)
|
||||
{
|
||||
var options = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
{
|
||||
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
|
||||
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
|
||||
var actualThumbprint = x509Cert.GetCertHashString();
|
||||
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
|
||||
|
||||
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError(
|
||||
"Server certificate thumbprint mismatch on fleet alert channel. Expected {Expected}, got {Actual}",
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug("Server certificate validated via pinning on fleet alert channel: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
|
||||
if (clientCert != null)
|
||||
{
|
||||
options.ClientCertificates = new X509CertificateCollection { clientCert };
|
||||
logger.LogInformation(
|
||||
"Attached client certificate to fleet alert channel: {Subject}",
|
||||
clientCert.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Finds certificate in LocalMachine\My store by thumbprint.
|
||||
private X509Certificate2? FindClientCertificate(string thumbprint)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
thumbprint.Trim(),
|
||||
validOnly: false);
|
||||
|
||||
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FleetAlert>> FetchAlertsAsync(
|
||||
string hostname, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -39,6 +39,54 @@ from markupsafe import escape
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
|
||||
|
||||
# TEST ENDPOINT: Client cert debugging (remove after testing)
|
||||
@endpoints_bp.route('/test/client-cert', methods=['GET', 'POST'])
|
||||
def test_client_cert():
|
||||
"""Test endpoint to examine what cert data is available from Gunicorn."""
|
||||
environ = request.environ
|
||||
cert_keys = {k: v for k, v in environ.items() if 'cert' in k.lower() or 'ssl' in k.lower() or 'tls' in k.lower()}
|
||||
|
||||
# Check for common WSGI cert keys
|
||||
peercert = environ.get('peercert')
|
||||
ssl_client_cert = environ.get('SSL_CLIENT_CERT')
|
||||
ssl_client_s_dn = environ.get('SSL_CLIENT_S_DN')
|
||||
|
||||
# Check for nginx-passed headers
|
||||
nginx_fp = request.headers.get('X-Client-Cert-Fingerprint')
|
||||
nginx_dn = request.headers.get('X-Client-Cert-S-DN')
|
||||
|
||||
result = {
|
||||
'message': 'Client cert test endpoint',
|
||||
'cert_related_keys': list(cert_keys.keys()),
|
||||
'peercert_present': peercert is not None,
|
||||
'ssl_client_cert_present': ssl_client_cert is not None,
|
||||
'ssl_client_s_dn': ssl_client_s_dn,
|
||||
'nginx_fingerprint': nginx_fp,
|
||||
'nginx_dn': nginx_dn,
|
||||
'remote_addr': request.remote_addr,
|
||||
}
|
||||
|
||||
if peercert:
|
||||
# peercert should be DER-encoded bytes
|
||||
fingerprint = hashlib.sha256(peercert).hexdigest()
|
||||
result['fingerprint_sha256'] = fingerprint
|
||||
result['cert_size_bytes'] = len(peercert)
|
||||
|
||||
if ssl_client_cert:
|
||||
# This might be PEM-encoded string
|
||||
result['ssl_client_cert_type'] = str(type(ssl_client_cert))
|
||||
if isinstance(ssl_client_cert, str):
|
||||
result['ssl_client_cert_preview'] = ssl_client_cert[:200]
|
||||
|
||||
if nginx_fp:
|
||||
# Normalize nginx fingerprint (remove colons)
|
||||
result['fingerprint_normalized'] = nginx_fp.replace(':', '').lower()
|
||||
|
||||
current_app.logger.info(f'[test/client-cert] nginx_fp={nginx_fp} remote_addr={request.remote_addr}')
|
||||
|
||||
return success_response(result)
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@@ -1976,11 +2024,26 @@ def _reap_stale_commands(asset_id):
|
||||
|
||||
|
||||
def _collector_auth_or_error():
|
||||
"""Authorize an agent-facing command route on the endpoints collector token.
|
||||
"""Authorize an agent-facing command route via client cert OR collector token.
|
||||
|
||||
Dual-auth during rollout:
|
||||
1. Try client certificate authentication first (per-endpoint mTLS)
|
||||
2. Fall back to collector API key (shared fleet-wide key)
|
||||
|
||||
Reuses the collector auth path so the command channel shares the endpoint
|
||||
agent key (kill switch) and introduces no second secret.
|
||||
"""
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
from flask import g
|
||||
|
||||
# Try client certificate authentication first
|
||||
cert_asset = authenticate_by_client_cert()
|
||||
if cert_asset:
|
||||
# Store authenticated asset ID in request context for later use
|
||||
g.cert_authenticated_asset_id = cert_asset.assetid
|
||||
return None # Success - no error
|
||||
|
||||
# Fall back to collector API key
|
||||
return collector_authorized('endpoints')
|
||||
|
||||
|
||||
@@ -3267,21 +3330,33 @@ def report_command_result(command_id: int):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No command {command_id}', http_code=404)
|
||||
|
||||
# An agent may only finalize ITS OWN command. The collector key is shared
|
||||
# fleet-wide, so without this any agent could post a result for any command
|
||||
# id - marking an install succeeded on a machine that never ran it, which
|
||||
# writes a false compliance record. Binding to the claimed hostname is a
|
||||
# floor, not a fix: the credential still does not prove which endpoint is
|
||||
# calling, and only a per-endpoint credential closes that.
|
||||
# Optional because the shipped agent does not send it yet; enforced when it
|
||||
# does, so an updated fleet gets the check without a flag day.
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if hostname:
|
||||
caller_asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if caller_asset_id != command.assetid:
|
||||
# Authorization: agent may only finalize ITS OWN command. The collector key is
|
||||
# shared fleet-wide, so without this any agent could post a result for any
|
||||
# command id - marking an install succeeded on a machine that never ran it,
|
||||
# which writes a false compliance record.
|
||||
#
|
||||
# Prefer cert-authenticated asset ID (per-endpoint mTLS) when available. Fall
|
||||
# back to hostname query parameter for agents not yet upgraded to certs. The
|
||||
# hostname binding is a floor, not a fix: the shared collector key does not
|
||||
# prove which endpoint is calling. Per-endpoint client certificates close that.
|
||||
if hasattr(g, 'cert_authenticated_asset_id'):
|
||||
# Strong authentication via client certificate
|
||||
caller_asset_id = g.cert_authenticated_asset_id
|
||||
else:
|
||||
# Weak authentication via hostname query parameter (fallback during rollout)
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
# No cert and no hostname: cannot verify caller identity
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This command belongs to another endpoint', http_code=403)
|
||||
'Command result requires client certificate or hostname parameter',
|
||||
http_code=403)
|
||||
caller_asset_id = _resolve_asset_for_hostname(hostname)
|
||||
|
||||
if caller_asset_id != command.assetid:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This command belongs to another endpoint', http_code=403)
|
||||
|
||||
# Only a claimed command can be finalized. Needs no agent change, and it
|
||||
# closes two things the hostname check cannot: reporting on a command that
|
||||
@@ -3905,24 +3980,35 @@ def get_fleet_alerts():
|
||||
# computer row means fleet-wide rather than a crash.
|
||||
facts = _endpoint_scope_facts(hostname) or {}
|
||||
|
||||
current_app.logger.debug(
|
||||
f'[fleet_alerts] hostname={hostname} asset_id={asset_id} '
|
||||
f'facts={facts} query_results={len(results)}')
|
||||
|
||||
alerts = []
|
||||
for notification, ntype in results:
|
||||
if not _alert_targets_endpoint(notification.alertscope, facts):
|
||||
matches = _alert_targets_endpoint(notification.alertscope, facts)
|
||||
current_app.logger.debug(
|
||||
f'[fleet_alerts] notification_id={notification.notificationid} '
|
||||
f'scope={notification.alertscope} matches={matches}')
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
title = notification.notification[:100] if notification.notification else ''
|
||||
if '\n' in title:
|
||||
title = title.split('\n')[0][:100] or '(no title)'
|
||||
|
||||
alerts.append({
|
||||
alert_data = {
|
||||
'notificationId': notification.notificationid,
|
||||
'type': ntype.typename,
|
||||
'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR,
|
||||
'title': title,
|
||||
'message': notification.notification or '',
|
||||
'timeout': notification.alerttimeout,
|
||||
})
|
||||
}
|
||||
alerts.append(alert_data)
|
||||
current_app.logger.debug(f'[fleet_alerts] added alert: {alert_data}')
|
||||
|
||||
current_app.logger.debug(f'[fleet_alerts] returning {len(alerts)} alerts')
|
||||
return success_response(alerts)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add certificate revocation columns to assets
|
||||
|
||||
Revision ID: endpoints0022certrevocation
|
||||
Revises: endpoints0021clientcerts
|
||||
Create Date: 2026-09-14
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'endpoints0022certrevocation'
|
||||
down_revision = 'endpoints0021clientcerts'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
plugin_name = 'endpoints'
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add revocation tracking columns to assets table
|
||||
op.add_column('assets',
|
||||
sa.Column('cert_revoked_at', sa.DateTime, nullable=True,
|
||||
comment='When the certificate was revoked (null = not revoked)'))
|
||||
|
||||
op.add_column('assets',
|
||||
sa.Column('cert_revocation_reason', sa.String(255), nullable=True,
|
||||
comment='Why the certificate was revoked (admin note)'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('assets', 'cert_revocation_reason')
|
||||
op.drop_column('assets', 'cert_revoked_at')
|
||||
@@ -15,6 +15,7 @@ and ShopDB, the same discipline the backups collector uses for machine numbers.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Type
|
||||
@@ -39,6 +40,10 @@ logger = logging.getLogger(__name__)
|
||||
# of a raw event firehose.
|
||||
CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
|
||||
|
||||
# Pre-compiled regex for fingerprint validation (64 lowercase hex chars).
|
||||
# Used in hot path (40,000+ validations/hour), so compile once at module load.
|
||||
_FP_REGEX = re.compile(r'\A[0-9a-f]{64}\Z')
|
||||
|
||||
# Top-level payload keys the ingest path knows about today - either persisted on
|
||||
# EndpointState, appended as events, or relayed to the computers collector.
|
||||
# Anything the agent sends outside this set is not stored yet; it is logged (see
|
||||
@@ -1004,47 +1009,242 @@ class EndpointsPlugin(BasePlugin):
|
||||
db.session.flush()
|
||||
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_fingerprint(fp: str) -> bool:
|
||||
"""Validate SHA256 fingerprint format: exactly 64 lowercase hex chars."""
|
||||
if not fp or not isinstance(fp, str):
|
||||
return False
|
||||
return _FP_REGEX.fullmatch(fp) is not None
|
||||
|
||||
def _replace_applications(self, assetid: int, applications,
|
||||
warnings: List[str]) -> int:
|
||||
"""Replace an asset's SBOM snapshot with the reported set.
|
||||
"""Update an asset's SBOM snapshot using delta comparison.
|
||||
|
||||
The agent reports the full installed-software list each cycle, so the
|
||||
current inventory is the newest report, not an accumulation. Delete the
|
||||
asset's existing rows and insert the reported ones in the same
|
||||
transaction the caller flushes. appid is left null: no
|
||||
discovery-to-catalog ("Managed Applications") matcher is wired yet.
|
||||
Malformed items (no displayname) are skipped, not fatal.
|
||||
The agent reports the full installed-software list each cycle along with
|
||||
a fingerprint hash (SHA256 of displayname|displayversion|publisher) for
|
||||
each entry. Rather than delete-all-insert-all, compare fingerprints to
|
||||
detect actual changes:
|
||||
- Add rows for new fingerprints
|
||||
- Remove rows for fingerprints no longer reported
|
||||
- Leave unchanged rows untouched
|
||||
|
||||
This reduces database writes from ~100 ops/endpoint/check-in to ~2 ops
|
||||
(99% of check-ins have no application changes). Malformed items (no
|
||||
displayname or fingerprint) are skipped, not fatal.
|
||||
|
||||
Legacy fallback: If agent does not send fingerprints, fall back to
|
||||
delete-all-insert-all for backward compatibility during rollout.
|
||||
|
||||
Safety features:
|
||||
- Row-level locking prevents concurrent update races
|
||||
- Fingerprint format validation (64 hex chars)
|
||||
- Delete count verification detects silent failures
|
||||
- Memory limits prevent pathological cases (>5000 apps)
|
||||
- Metrics logged for monitoring
|
||||
"""
|
||||
EndpointApplication.query.filter_by(assetid=assetid).delete(
|
||||
synchronize_session=False)
|
||||
from flask import current_app
|
||||
|
||||
# Memory safety: prevent loading massive inventories
|
||||
MAX_APPS_PER_ENDPOINT = 5000
|
||||
if not applications:
|
||||
# Empty list means no apps installed - delete all existing
|
||||
deleted = EndpointApplication.query.filter_by(assetid=assetid).delete(
|
||||
synchronize_session=False)
|
||||
current_app.logger.info(
|
||||
f'[collector] asset_id={assetid} apps_removed={deleted} '
|
||||
f'apps_added=0 (empty list)')
|
||||
return 0
|
||||
|
||||
stored = 0
|
||||
# Check if agent sent fingerprints - legacy agents won't have them yet
|
||||
has_fingerprints = any(item.get('fingerprint') for item in applications
|
||||
if isinstance(item, dict))
|
||||
|
||||
if not has_fingerprints:
|
||||
# LEGACY FALLBACK: No fingerprints, use delete-all-insert-all
|
||||
# SAFETY: Clear any existing NULL-fingerprint apps first to prevent
|
||||
# UNIQUE constraint violations (MySQL allows multiple NULL values)
|
||||
deleted = EndpointApplication.query.filter_by(assetid=assetid).delete(
|
||||
synchronize_session=False)
|
||||
current_app.logger.info(
|
||||
f'[collector] asset_id={assetid} legacy_delete={deleted} apps')
|
||||
|
||||
stored = 0
|
||||
for item in applications:
|
||||
if not isinstance(item, dict):
|
||||
warnings.append('skipped non-object application entry')
|
||||
continue
|
||||
displayname = (item.get('displayname') or '').strip()
|
||||
if not displayname:
|
||||
warnings.append('skipped application with no displayname')
|
||||
continue
|
||||
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid,
|
||||
displayname=displayname[:255],
|
||||
displayversion=_clamp(item.get('displayversion'), 128),
|
||||
publisher=_clamp(item.get('publisher'), 255),
|
||||
app_fingerprint=None, # Agent didn't send it
|
||||
))
|
||||
stored += 1
|
||||
|
||||
# Flush to detect constraint violations early
|
||||
db.session.flush()
|
||||
|
||||
current_app.logger.info(
|
||||
f'[collector] asset_id={assetid} apps_stored={stored} (legacy mode)'
|
||||
)
|
||||
|
||||
return stored
|
||||
|
||||
# DELTA LOGIC: Build incoming fingerprint map and validate entries
|
||||
incoming = {}
|
||||
for item in applications:
|
||||
if not isinstance(item, dict):
|
||||
warnings.append('skipped non-object application entry')
|
||||
continue
|
||||
|
||||
displayname = (item.get('displayname') or '').strip()
|
||||
fingerprint = (item.get('fingerprint') or '').strip()
|
||||
|
||||
if not displayname:
|
||||
warnings.append('skipped application with no displayname')
|
||||
continue
|
||||
# TRUNCATE EVERY STRING, not just the name. On MySQL in strict
|
||||
# mode an over-long value raises Data too long and takes down the
|
||||
# WHOLE check-in transaction, so one machine with a malformed
|
||||
# Uninstall registry entry stops reporting entirely - telemetry,
|
||||
# events and all - until somebody uninstalls the offender. SQLite
|
||||
# does not enforce declared lengths, so no test here would ever see
|
||||
# it. displayname was already clamped; these two were not.
|
||||
if not fingerprint:
|
||||
warnings.append('skipped application with no fingerprint')
|
||||
continue
|
||||
|
||||
# SAFETY: Validate fingerprint format (64 lowercase hex chars)
|
||||
if not self._is_valid_fingerprint(fingerprint):
|
||||
warnings.append(
|
||||
f'skipped application with invalid fingerprint: {displayname} '
|
||||
f'(fingerprint: {fingerprint[:32]}...)'
|
||||
)
|
||||
continue
|
||||
|
||||
# Truncate strings for MySQL strict mode compatibility
|
||||
incoming[fingerprint] = {
|
||||
'displayname': displayname[:255],
|
||||
'displayversion': _clamp(item.get('displayversion'), 128),
|
||||
'publisher': _clamp(item.get('publisher'), 255),
|
||||
}
|
||||
|
||||
incoming_fps = set(incoming.keys())
|
||||
|
||||
# SAFETY: Row-level locking to prevent concurrent update races.
|
||||
# MySQL uses FOR UPDATE on the SELECT below (line 1157).
|
||||
# PostgreSQL would use pg_advisory_xact_lock here, but MySQL achieves
|
||||
# the same result via row-level locks + UNIQUE constraint on fingerprint.
|
||||
|
||||
# SAFETY: Memory limit check - prevent loading massive inventories
|
||||
# Query exactly MAX rows, then check if more exist (off-by-one fix)
|
||||
current_apps = (EndpointApplication.query
|
||||
.filter_by(assetid=assetid)
|
||||
.limit(MAX_APPS_PER_ENDPOINT)
|
||||
.with_for_update() # SAFETY: Row-level locking prevents concurrent races
|
||||
.all())
|
||||
|
||||
if len(current_apps) >= MAX_APPS_PER_ENDPOINT:
|
||||
# This endpoint has too many apps - fall back to legacy mode
|
||||
warnings.append(
|
||||
f'asset has {len(current_apps)} apps (exceeds {MAX_APPS_PER_ENDPOINT}), '
|
||||
f'falling back to legacy mode'
|
||||
)
|
||||
# Recursive call with has_fingerprints=False to trigger legacy path
|
||||
# (This is safe because we're already in a transaction)
|
||||
EndpointApplication.query.filter_by(assetid=assetid).delete(
|
||||
synchronize_session=False)
|
||||
|
||||
stored = 0
|
||||
for fp, app_data in incoming.items():
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid,
|
||||
displayname=app_data['displayname'],
|
||||
displayversion=app_data['displayversion'],
|
||||
publisher=app_data['publisher'],
|
||||
app_fingerprint=fp,
|
||||
))
|
||||
stored += 1
|
||||
|
||||
db.session.flush()
|
||||
|
||||
current_app.logger.warning(
|
||||
f'[collector] asset_id={assetid} apps_stored={stored} '
|
||||
f'(legacy mode - memory limit exceeded, had {len(current_apps)} apps)'
|
||||
)
|
||||
|
||||
return stored
|
||||
|
||||
current_fps = {app.app_fingerprint for app in current_apps
|
||||
if app.app_fingerprint} # Skip NULL fingerprints from migration
|
||||
|
||||
# TRANSITION: If we have apps without fingerprints, this is the first report
|
||||
# after agent upgrade. Delete all NULL-fingerprint apps to prevent duplicates.
|
||||
# SAFETY: Validate incoming apps BEFORE deleting legacy apps to prevent
|
||||
# leaving asset with zero apps if transaction aborts.
|
||||
null_fp_apps = [app for app in current_apps if app.app_fingerprint is None]
|
||||
if null_fp_apps:
|
||||
if not incoming_fps:
|
||||
current_app.logger.error(
|
||||
f'[collector] asset_id={assetid} received empty fingerprint set '
|
||||
f'during transition, keeping {len(null_fp_apps)} legacy apps')
|
||||
warnings.append(
|
||||
f'agent sent empty fingerprint set during NULL transition, '
|
||||
f'kept {len(null_fp_apps)} legacy apps')
|
||||
return len(null_fp_apps)
|
||||
|
||||
for app in null_fp_apps:
|
||||
db.session.delete(app)
|
||||
current_app.logger.info(
|
||||
f'[collector] asset_id={assetid} removed {len(null_fp_apps)} '
|
||||
f'legacy apps (transition to delta mode)')
|
||||
|
||||
# Calculate delta
|
||||
to_add = incoming_fps - current_fps # New apps
|
||||
to_remove = current_fps - incoming_fps # Uninstalled apps
|
||||
unchanged = current_fps & incoming_fps # Unchanged (don't touch)
|
||||
|
||||
# Remove uninstalled apps
|
||||
deleted_count = 0
|
||||
if to_remove:
|
||||
deleted_count = EndpointApplication.query.filter(
|
||||
EndpointApplication.assetid == assetid,
|
||||
EndpointApplication.app_fingerprint.in_(list(to_remove))
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# SAFETY: Verify delete count matches expected. Log as WARNING
|
||||
# instead of raising error - concurrent updates are rare but valid,
|
||||
# and aborting the entire transaction loses state/events/hardware data.
|
||||
if deleted_count != len(to_remove):
|
||||
current_app.logger.warning(
|
||||
f'[collector] asset_id={assetid} delete_count_mismatch: '
|
||||
f'expected={len(to_remove)} actual={deleted_count} '
|
||||
f'(possible concurrent update, proceeding with caution)')
|
||||
warnings.append(
|
||||
f'delete count mismatch: expected {len(to_remove)}, '
|
||||
f'got {deleted_count} (concurrent update?)')
|
||||
|
||||
# Add new apps
|
||||
for fp in to_add:
|
||||
app_data = incoming[fp]
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid,
|
||||
displayname=displayname[:255],
|
||||
displayversion=_clamp(item.get('displayversion'), 128),
|
||||
publisher=_clamp(item.get('publisher'), 255),
|
||||
displayname=app_data['displayname'],
|
||||
displayversion=app_data['displayversion'],
|
||||
publisher=app_data['publisher'],
|
||||
app_fingerprint=fp,
|
||||
))
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
# SAFETY: Flush to detect constraint violations before continuing
|
||||
# Only flush if we made changes (skip when unchanged apps = 99% of cases)
|
||||
if to_add or to_remove:
|
||||
db.session.flush()
|
||||
|
||||
current_app.logger.info(
|
||||
f'[collector] asset_id={assetid} apps_added={len(to_add)} '
|
||||
f'apps_removed={len(to_remove)} unchanged={len(unchanged)} (delta mode)'
|
||||
)
|
||||
|
||||
return len(to_add)
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotently upsert one endpoint's state and append its events.
|
||||
@@ -1091,6 +1291,21 @@ class EndpointsPlugin(BasePlugin):
|
||||
|
||||
assetid = computer.assetid
|
||||
|
||||
# Authorization: if authenticated via client certificate, verify the
|
||||
# certificate's asset matches the payload's hostname asset. Without this
|
||||
# check an agent with a valid cert for asset A can submit telemetry for
|
||||
# asset B by changing the hostname field.
|
||||
from flask import g
|
||||
if hasattr(g, 'cert_authenticated_asset_id'):
|
||||
if g.cert_authenticated_asset_id != assetid:
|
||||
self.logger.warning(
|
||||
'[collector] AUTHORIZATION DENIED: Certificate authenticates '
|
||||
'asset %d but payload hostname %s resolves to asset %d',
|
||||
g.cert_authenticated_asset_id, hostname, assetid)
|
||||
raise PermissionError(
|
||||
f'Certificate authenticates asset {g.cert_authenticated_asset_id} '
|
||||
f'but payload reports for asset {assetid}')
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=assetid).first()
|
||||
action = 'updated'
|
||||
if state is None:
|
||||
@@ -1257,11 +1472,16 @@ class EndpointsPlugin(BasePlugin):
|
||||
if eventrecordid is not None]
|
||||
already_stored = set()
|
||||
if keyed:
|
||||
# SAFETY: Row-level locking prevents race condition (vuln #7 from audit).
|
||||
# WITH FOR UPDATE locks matching rows in MySQL, preventing concurrent INSERT
|
||||
# of duplicates. The UNIQUE constraint on (assetid, eventchannel, eventrecordid)
|
||||
# provides additional protection against race conditions.
|
||||
rows = (db.session.query(EndpointEvent.eventchannel,
|
||||
EndpointEvent.eventrecordid)
|
||||
.filter(EndpointEvent.assetid == assetid,
|
||||
EndpointEvent.eventrecordid.in_(
|
||||
[recordid for _c, recordid in keyed]))
|
||||
.with_for_update()
|
||||
.all())
|
||||
already_stored = {(channel, recordid) for channel, recordid in rows}
|
||||
|
||||
@@ -1289,9 +1509,14 @@ class EndpointsPlugin(BasePlugin):
|
||||
stored_events += 1
|
||||
|
||||
if skipped_events:
|
||||
logger.info(
|
||||
'[collector] asset_id=%d hostname=%s events_stored=%d '
|
||||
'events_skipped=%d (duplicates detected)',
|
||||
assetid, hostname, stored_events, skipped_events)
|
||||
else:
|
||||
logger.debug(
|
||||
'endpoint %s replayed %d already-stored event record(s)',
|
||||
hostname, skipped_events)
|
||||
'endpoint %s stored %d event(s), no duplicates',
|
||||
hostname, stored_events)
|
||||
|
||||
# flush, not commit: the collector dispatcher owns the transaction.
|
||||
db.session.flush()
|
||||
|
||||
@@ -13,6 +13,7 @@ from flask import Blueprint, request, current_app, g
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, Application
|
||||
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
@@ -241,11 +242,21 @@ def generic_collect(pluginname):
|
||||
|
||||
plugin, schema = plugins[pluginname]
|
||||
|
||||
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header
|
||||
# only (querystring fallback dropped, see require_api_key).
|
||||
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
|
||||
if error is not None:
|
||||
return error
|
||||
# Dual-auth: try client certificate first (per-endpoint mTLS), fall back to
|
||||
# per-plugin env key or collector-scoped managed token. Header only
|
||||
# (querystring fallback dropped, see require_api_key).
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
cert_asset = authenticate_by_client_cert()
|
||||
if cert_asset:
|
||||
g.cert_authenticated_asset_id = cert_asset.assetid
|
||||
current_app.logger.debug(
|
||||
'[collector] Authenticated via client cert for asset %d',
|
||||
cert_asset.assetid)
|
||||
else:
|
||||
# No cert or cert invalid: fall back to API key
|
||||
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if not payload or not isinstance(payload, dict):
|
||||
|
||||
0
shopdb/core/auth/__init__.py
Normal file
0
shopdb/core/auth/__init__.py
Normal file
211
shopdb/core/auth/client_cert.py
Normal file
211
shopdb/core/auth/client_cert.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Client certificate authentication helpers.
|
||||
|
||||
Extracts client certificate fingerprints from various deployment types:
|
||||
- IIS 2019: SSL_CLIENT_CERT environ (PEM format)
|
||||
- Nginx: X-Client-Cert-Fingerprint header (SHA1 hex, colon-separated)
|
||||
- IIS with ARR: X-ARR-ClientCert header (base64 DER)
|
||||
|
||||
SECURITY: Nginx $ssl_client_fingerprint outputs SHA1 (40 hex chars), not SHA256.
|
||||
This is a known Nginx limitation. SHA1 is deprecated for signing but remains
|
||||
acceptable for fingerprinting self-signed certificates in air-gapped environments
|
||||
with short-lived certs and revocation support.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import hmac
|
||||
import re
|
||||
from flask import request, current_app
|
||||
|
||||
|
||||
def get_client_cert_fingerprint():
|
||||
"""Extract client certificate fingerprint from request.
|
||||
|
||||
Returns:
|
||||
str: 40-character lowercase hex SHA1 fingerprint, or None if no cert
|
||||
|
||||
Supports multiple deployment types:
|
||||
- Nginx: reads X-Client-Cert-Fingerprint header (SHA1, colon-separated)
|
||||
- IIS direct: reads SSL_CLIENT_CERT environ (PEM format, computes SHA256)
|
||||
- IIS with ARR: reads X-ARR-ClientCert header (base64 DER, computes SHA256)
|
||||
|
||||
SECURITY: Nginx returns SHA1 (40 hex chars). IIS paths compute SHA256 (64 hex
|
||||
chars). The database client_cert_fingerprint column holds 64 chars to support
|
||||
future SHA256 migration but currently stores SHA1 from Nginx deployments.
|
||||
"""
|
||||
|
||||
# Option 1: Nginx passes pre-computed SHA1 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
|
||||
normalized = nginx_fp.replace(':', '').lower()
|
||||
|
||||
# Validate format: must be 40 hex chars (SHA1) or 64 hex chars (SHA256)
|
||||
if len(normalized) == 40:
|
||||
# SHA1 from nginx $ssl_client_fingerprint
|
||||
if not re.match(r'^[0-9a-f]{40}$', normalized):
|
||||
current_app.logger.warning(
|
||||
f'[client_cert] Invalid SHA1 fingerprint format: {normalized[:16]}...')
|
||||
return None
|
||||
return normalized
|
||||
elif len(normalized) == 64:
|
||||
# SHA256 from custom nginx config or future migration
|
||||
if not re.match(r'^[0-9a-f]{64}$', normalized):
|
||||
current_app.logger.warning(
|
||||
f'[client_cert] Invalid SHA256 fingerprint format: {normalized[:16]}...')
|
||||
return None
|
||||
return normalized
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
f'[client_cert] Invalid fingerprint length: {len(normalized)} '
|
||||
f'(expected 40 for SHA1 or 64 for SHA256)')
|
||||
return None
|
||||
|
||||
# Option 2: IIS passes full cert in PEM format via WSGI environ
|
||||
iis_cert_pem = request.environ.get('SSL_CLIENT_CERT')
|
||||
if iis_cert_pem:
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
# 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 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 revoked?
|
||||
4. Certificate not expired?
|
||||
|
||||
Does NOT raise exceptions - returns None on any failure.
|
||||
Caller decides whether to fall back to other auth methods.
|
||||
|
||||
AUDIT: Logs all authentication attempts (success and failure) to AuditLog.
|
||||
"""
|
||||
from shopdb.api import Asset, AuditLog
|
||||
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)')
|
||||
# Audit: Failed authentication attempt with unknown fingerprint
|
||||
try:
|
||||
AuditLog.log(
|
||||
action='auth.cert.unknown',
|
||||
userid=None,
|
||||
details=f'Unknown fingerprint: {fingerprint[:16]}...')
|
||||
except Exception:
|
||||
pass # Don't fail authentication on audit failure
|
||||
return None
|
||||
|
||||
# Constant-time comparison to prevent timing attacks. Database lookup already
|
||||
# happened (timing leak there is unavoidable with indexed lookups), but this
|
||||
# prevents attackers from using response timing to confirm partial fingerprint
|
||||
# matches during the final validation.
|
||||
if not hmac.compare_digest(asset.client_cert_fingerprint, fingerprint):
|
||||
current_app.logger.warning(
|
||||
f'[client_cert] Fingerprint mismatch for asset {asset.assetid} '
|
||||
f'(database vs request)')
|
||||
return None
|
||||
|
||||
# Check certificate revocation (cert_revoked_at is set when admin revokes)
|
||||
if hasattr(asset, 'cert_revoked_at') and asset.cert_revoked_at:
|
||||
current_app.logger.warning(
|
||||
f'[client_cert] Revoked certificate for asset {asset.assetid} '
|
||||
f'(revoked {asset.cert_revoked_at})')
|
||||
# Audit: Attempt to use revoked certificate
|
||||
try:
|
||||
AuditLog.log(
|
||||
action='auth.cert.revoked',
|
||||
userid=None,
|
||||
entityid=asset.assetid,
|
||||
entityname=asset.assetnumber,
|
||||
details=f'Revoked at {asset.cert_revoked_at}: '
|
||||
f'{asset.cert_revocation_reason or "no reason"}')
|
||||
except Exception:
|
||||
pass
|
||||
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})')
|
||||
# Audit: Attempt to use expired certificate
|
||||
try:
|
||||
AuditLog.log(
|
||||
action='auth.cert.expired',
|
||||
userid=None,
|
||||
entityid=asset.assetid,
|
||||
entityname=asset.assetnumber,
|
||||
details=f'Expired {asset.cert_expires_at}')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
current_app.logger.info(
|
||||
f'[client_cert] Authenticated asset {asset.assetid} '
|
||||
f'(number={asset.assetnumber}) via certificate {fingerprint[:16]}...')
|
||||
|
||||
# Audit: Successful certificate authentication
|
||||
try:
|
||||
AuditLog.log(
|
||||
action='auth.cert.success',
|
||||
userid=None,
|
||||
entityid=asset.assetid,
|
||||
entityname=asset.assetnumber,
|
||||
details=f'Cert fingerprint: {fingerprint[:16]}...')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return asset
|
||||
@@ -139,6 +139,34 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Client certificate authentication (endpoints plugin)
|
||||
client_cert_fingerprint = db.Column(
|
||||
db.String(64),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment='SHA1 or SHA256 fingerprint of client certificate for mTLS auth'
|
||||
)
|
||||
cert_issued_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=True,
|
||||
comment='When the client certificate was issued'
|
||||
)
|
||||
cert_expires_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=True,
|
||||
comment='When the certificate expires'
|
||||
)
|
||||
cert_revoked_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=True,
|
||||
comment='When the certificate was revoked (null = not revoked)'
|
||||
)
|
||||
cert_revocation_reason = db.Column(
|
||||
db.String(255),
|
||||
nullable=True,
|
||||
comment='Why the certificate was revoked (admin note)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
assettype = db.relationship('AssetType', backref='assets')
|
||||
status = db.relationship('AssetStatus', backref='assets')
|
||||
|
||||
Reference in New Issue
Block a user