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:
Proudlock
2026-09-14 13:58:02 -04:00
parent d5d2cea3e4
commit 18e5fdc000
11 changed files with 913 additions and 47 deletions

View File

@@ -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;

View File

@@ -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)
{

View File

@@ -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

View File

@@ -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)
{