Skip to content

Instantly share code, notes, and snippets.

@swaters86
Created February 20, 2026 21:05
Show Gist options
  • Select an option

  • Save swaters86/63aab11f750489059aecc69332995323 to your computer and use it in GitHub Desktop.

Select an option

Save swaters86/63aab11f750489059aecc69332995323 to your computer and use it in GitHub Desktop.
// MtlsEnrollmentClient.Csr.Production.Net10.cs
// .NET 10 Worker Service helper (CSR-based enrollment, production-upgraded)
//
// Features:
// - Generates private key locally and creates CSR (PKCS#10)
// - Sends CSR + enrollment code to API /enroll/csr
// - Installs returned signed cert and associates with local private key
// - mTLS HttpClient creation
// - Renewal via /cert/renew/csr using mTLS + fresh CSR
// - Thumbprint + DeviceId persisted in HKLM registry using DPAPI
// - Retry/backoff for enroll/renew
// - Best-effort private key ACL hardening for service account
//
// Target: .NET 10 Worker Service on Windows (net10.0-windows recommended)
//
// appsettings example:
// {
// "ApiSettings": {
// "BaseUrl": "https://api.yourdomain.com/",
// "EnrollmentCode": "ABC-123-ENROLL",
// "TenantSubjectMarker": "OU=tenant_demo_001",
// "RegistryPath": "Software\\YourCompany\\IntegrationAgent\\Security",
// "ServiceAccountIdentity": "NT SERVICE\\YourWorkerService"
// }
// }
using Microsoft.Win32;
using System.Net.Http;
using System.Security.AccessControl;
using System.Security.Authentication;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
public sealed class MtlsEnrollmentClient
{
private readonly Uri _baseApi;
private readonly string _tenantSubjectMarkerFallback;
private readonly string _registryPath;
private readonly string? _serviceAccountIdentity;
private readonly TimeSpan _timeout;
private readonly JsonSerializerOptions _jsonOptions;
private readonly byte[] _dpapiEntropy;
public string? CurrentThumbprint { get; private set; }
public string? CurrentDeviceId { get; private set; }
public MtlsEnrollmentClient(
string baseApiUrl,
string tenantSubjectMarkerFallback,
string registryPath = @"Software\YourCompany\IntegrationAgent\Security",
string? serviceAccountIdentity = null,
TimeSpan? timeout = null)
{
if (string.IsNullOrWhiteSpace(baseApiUrl)) throw new ArgumentNullException(nameof(baseApiUrl));
if (string.IsNullOrWhiteSpace(tenantSubjectMarkerFallback)) throw new ArgumentNullException(nameof(tenantSubjectMarkerFallback));
if (string.IsNullOrWhiteSpace(registryPath)) throw new ArgumentNullException(nameof(registryPath));
_baseApi = new Uri(baseApiUrl.TrimEnd('/') + "/");
_tenantSubjectMarkerFallback = tenantSubjectMarkerFallback;
_registryPath = registryPath;
_serviceAccountIdentity = serviceAccountIdentity;
_timeout = timeout ?? TimeSpan.FromSeconds(30);
_dpapiEntropy = Encoding.UTF8.GetBytes("YourCompany|IntegrationAgent|mTLS-CSR|v1");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
}
// ============================================================
// Public API
// ============================================================
/// <summary>
/// Ensures client cert exists. If not, performs CSR enrollment.
/// </summary>
public async Task EnsureClientCertificateInstalledAsync(string? enrollmentCode, CancellationToken ct = default)
{
// 1) Exact thumbprint lookup from registry
var cert = FindCertByPersistedThumbprint();
if (cert is not null)
{
CurrentThumbprint = cert.Thumbprint;
CurrentDeviceId = LoadPersistedDeviceId();
return;
}
// 2) Fallback subject search (legacy/migration safety)
cert = FindNewestValidCertBySubjectMarker();
if (cert is not null)
{
CurrentThumbprint = cert.Thumbprint;
PersistThumbprint(cert.Thumbprint!);
CurrentDeviceId = LoadPersistedDeviceId();
return;
}
// 3) Need enrollment code for first enrollment
if (string.IsNullOrWhiteSpace(enrollmentCode))
throw new InvalidOperationException("No client certificate installed and enrollmentCode was not provided.");
// Generate local key + CSR (private key never leaves machine)
using var rsa = RSA.Create(3072);
// Subject used in CSR; API can override/normalize if it wants.
// Include machine name for auditability.
string subject = $"CN=client-{Sanitize(Environment.MachineName)}, O=YourCompany";
byte[] csrDer = CreateClientCsrDer(rsa, subject);
var request = new CsrEnrollRequest
{
EnrollmentCode = enrollmentCode.Trim(),
MachineName = Environment.MachineName,
CsrBase64 = Convert.ToBase64String(csrDer)
};
var enrollResponse = await ExecuteWithRetryAsync(async () =>
{
return await PostJsonAsync<CsrEnrollResponse>(
new Uri(_baseApi, "enroll/csr"),
request,
CreateHandlerNoClientCert(),
ct).ConfigureAwait(false);
}, ct).ConfigureAwait(false);
if (enrollResponse is null || string.IsNullOrWhiteSpace(enrollResponse.CertBase64))
throw new InvalidOperationException("CSR enrollment failed: empty certificate response.");
// Install cert by attaching returned cert to local private key
byte[] certDer = Convert.FromBase64String(enrollResponse.CertBase64);
using var issuedCertNoKey = new X509Certificate2(certDer);
// Attach local private key to the issued cert
using var certWithKeyTemp = issuedCertNoKey.CopyWithPrivateKey(rsa);
// Persist to LocalMachine\My
var installed = PersistCertificateWithPrivateKeyToLocalMachine(certWithKeyTemp);
// Best-effort ACL hardening for service account
TryHardenPrivateKeyAcl(installed, _serviceAccountIdentity);
CurrentThumbprint = installed.Thumbprint;
CurrentDeviceId = enrollResponse.DeviceId;
PersistThumbprint(installed.Thumbprint!);
if (!string.IsNullOrWhiteSpace(enrollResponse.DeviceId))
PersistDeviceId(enrollResponse.DeviceId!);
}
/// <summary>
/// Creates an HttpClient using the installed client certificate (mTLS).
/// Caller is responsible for disposing it.
/// </summary>
public HttpClient CreateMtlsHttpClient()
{
var cert = FindCertByPersistedThumbprint() ?? FindNewestValidCertBySubjectMarker();
if (cert is null)
throw new InvalidOperationException("Client certificate not installed. Call EnsureClientCertificateInstalledAsync first.");
CurrentThumbprint = cert.Thumbprint;
var handler = new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
ClientCertificateOptions = ClientCertificateOption.Manual
};
handler.ClientCertificates.Add(cert);
// DO NOT disable server cert validation in production.
// handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
var client = new HttpClient(handler)
{
BaseAddress = _baseApi,
Timeout = _timeout
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("YourWorkerService/1.0");
var deviceId = LoadPersistedDeviceId();
if (!string.IsNullOrWhiteSpace(deviceId))
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Device-Id", deviceId); // diagnostics only (server should not trust for auth)
return client;
}
/// <summary>
/// Renews cert when close to expiry by generating a NEW local key + CSR and sending it over mTLS.
/// </summary>
public async Task<bool> TryRenewCertificateAsync(int renewWhenWithinDays = 14, CancellationToken ct = default)
{
var existing = FindCertByPersistedThumbprint() ?? FindNewestValidCertBySubjectMarker();
if (existing is null) return false;
CurrentThumbprint = existing.Thumbprint;
if (existing.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddDays(renewWhenWithinDays))
return true; // not due yet
try
{
// Generate new local private key + CSR for rotation
using var rsa = RSA.Create(3072);
string subject = $"CN=client-{Sanitize(Environment.MachineName)}, O=YourCompany";
byte[] csrDer = CreateClientCsrDer(rsa, subject);
var req = new CsrRenewRequest
{
CsrBase64 = Convert.ToBase64String(csrDer)
};
var renewResponse = await ExecuteWithRetryAsync(async () =>
{
using var http = CreateMtlsHttpClient();
using var content = new StringContent(JsonSerializer.Serialize(req), Encoding.UTF8, "application/json");
using var resp = await http.PostAsync("cert/renew/csr", content, ct).ConfigureAwait(false);
var respText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Renew CSR failed HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}: {respText}");
return JsonSerializer.Deserialize<CsrEnrollResponse>(respText, _jsonOptions);
}, ct).ConfigureAwait(false);
if (renewResponse is null || string.IsNullOrWhiteSpace(renewResponse.CertBase64))
return false;
byte[] certDer = Convert.FromBase64String(renewResponse.CertBase64);
using var issuedCertNoKey = new X509Certificate2(certDer);
using var certWithKeyTemp = issuedCertNoKey.CopyWithPrivateKey(rsa);
var installed = PersistCertificateWithPrivateKeyToLocalMachine(certWithKeyTemp);
TryHardenPrivateKeyAcl(installed, _serviceAccountIdentity);
CurrentThumbprint = installed.Thumbprint;
PersistThumbprint(installed.Thumbprint!);
if (!string.IsNullOrWhiteSpace(renewResponse.DeviceId))
{
CurrentDeviceId = renewResponse.DeviceId;
PersistDeviceId(renewResponse.DeviceId!);
}
// Optional cleanup: remove old cert after successful rotation
TryRemoveCertificate(existing);
return true;
}
catch
{
return false;
}
}
// ============================================================
// CSR generation
// ============================================================
/// <summary>
/// Creates a PKCS#10 CSR (DER bytes) for client auth.
/// </summary>
private static byte[] CreateClientCsrDer(RSA rsa, string subjectName)
{
var dn = new X500DistinguishedName(subjectName);
var req = new CertificateRequest(dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Client Authentication EKU
var eku = new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") };
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(eku, critical: true));
// Digital signature + key encipherment for TLS client auth
req.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
critical: true));
// Subject Key Identifier helps some PKI toolchains
req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, critical: false));
// DER-encoded PKCS#10
return req.CreateSigningRequest();
}
// ============================================================
// HTTP + retry
// ============================================================
private HttpClientHandler CreateHandlerNoClientCert()
{
return new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
};
}
private async Task<T?> PostJsonAsync<T>(Uri url, object body, HttpClientHandler handler, CancellationToken ct) where T : class
{
using var client = new HttpClient(handler) { Timeout = _timeout };
using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await client.PostAsync(url, content, ct).ConfigureAwait(false);
string responseText = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"HTTP {(int)response.StatusCode} {response.ReasonPhrase} :: {responseText}");
return JsonSerializer.Deserialize<T>(responseText, _jsonOptions);
}
private static async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> action, CancellationToken ct, int maxAttempts = 4)
{
Exception? last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
ct.ThrowIfCancellationRequested();
try
{
return await action().ConfigureAwait(false);
}
catch (Exception ex) when (attempt < maxAttempts && IsTransient(ex))
{
last = ex;
int backoffMs = (int)Math.Pow(2, attempt - 1) * 1000;
int jitterMs = Random.Shared.Next(100, 400);
await Task.Delay(backoffMs + jitterMs, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
last = ex;
break;
}
}
throw last ?? new InvalidOperationException("Retry operation failed.");
}
private static bool IsTransient(Exception ex)
{
return ex is HttpRequestException || ex is IOException || ex is TaskCanceledException;
}
// ============================================================
// Certificate persistence / lookup
// ============================================================
private static X509Certificate2 PersistCertificateWithPrivateKeyToLocalMachine(X509Certificate2 certWithKey)
{
// Export + re-import to ensure machine-context persisted private key
// (private key generated locally and remains local)
byte[] pfx = certWithKey.Export(X509ContentType.Pfx, string.Empty);
using var persisted = new X509Certificate2(
pfx,
string.Empty,
X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet);
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadWrite);
store.Add(persisted);
string thumb = NormalizeThumbprint(persisted.Thumbprint ?? string.Empty);
var found = store.Certificates
.OfType<X509Certificate2>()
.Where(c => string.Equals(NormalizeThumbprint(c.Thumbprint ?? string.Empty), thumb, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(c => c.NotAfter)
.FirstOrDefault();
return found ?? persisted;
}
private X509Certificate2? FindCertByPersistedThumbprint()
{
var thumb = LoadPersistedThumbprint();
if (string.IsNullOrWhiteSpace(thumb)) return null;
thumb = NormalizeThumbprint(thumb);
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var cert = store.Certificates
.OfType<X509Certificate2>()
.FirstOrDefault(c =>
c.HasPrivateKey &&
!string.IsNullOrWhiteSpace(c.Thumbprint) &&
string.Equals(NormalizeThumbprint(c.Thumbprint), thumb, StringComparison.OrdinalIgnoreCase) &&
c.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddMinutes(5));
if (cert is null)
DeletePersistedThumbprint();
return cert;
}
private X509Certificate2? FindNewestValidCertBySubjectMarker()
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
return store.Certificates
.OfType<X509Certificate2>()
.Where(c =>
c.HasPrivateKey &&
!string.IsNullOrWhiteSpace(c.Subject) &&
c.Subject.Contains(_tenantSubjectMarkerFallback, StringComparison.OrdinalIgnoreCase) &&
c.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddMinutes(5))
.OrderByDescending(c => c.NotAfter)
.FirstOrDefault();
}
private static void TryRemoveCertificate(X509Certificate2 cert)
{
try
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadWrite);
store.Remove(cert);
}
catch
{
// Best-effort cleanup only
}
}
/// <summary>
/// Best-effort grant GenericRead to service account on private key (CNG keys).
/// </summary>
private static void TryHardenPrivateKeyAcl(X509Certificate2 cert, string? serviceAccountIdentity)
{
if (cert is null || !cert.HasPrivateKey || string.IsNullOrWhiteSpace(serviceAccountIdentity))
return;
try
{
using RSA? rsa = cert.GetRSAPrivateKey();
if (rsa is RSACng rsaCng)
{
var key = rsaCng.Key;
var sec = key.GetAccessControl();
sec.AddAccessRule(new CngAccessRule(
new NTAccount(serviceAccountIdentity),
CngKeyRights.GenericRead,
AccessControlType.Allow));
key.SetAccessControl(sec);
return;
}
using ECDsa? ecdsa = cert.GetECDsaPrivateKey();
if (ecdsa is ECDsaCng ecdsaCng)
{
var key = ecdsaCng.Key;
var sec = key.GetAccessControl();
sec.AddAccessRule(new CngAccessRule(
new NTAccount(serviceAccountIdentity),
CngKeyRights.GenericRead,
AccessControlType.Allow));
key.SetAccessControl(sec);
return;
}
}
catch
{
// Best effort only (log in your worker if desired)
}
}
// ============================================================
// Registry + DPAPI persistence
// ============================================================
private void PersistThumbprint(string thumbprint)
=> WriteProtectedRegistryString("ClientCertThumbprint", NormalizeThumbprint(thumbprint));
private string? LoadPersistedThumbprint()
=> ReadProtectedRegistryString("ClientCertThumbprint");
private void DeletePersistedThumbprint()
{
using var key = Registry.LocalMachine.CreateSubKey(_registryPath, writable: true);
key?.DeleteValue("ClientCertThumbprint", throwOnMissingValue: false);
}
private void PersistDeviceId(string deviceId)
=> WriteProtectedRegistryString("DeviceId", deviceId);
public string? LoadPersistedDeviceId()
{
var v = ReadProtectedRegistryString("DeviceId");
CurrentDeviceId = v;
return v;
}
private void WriteProtectedRegistryString(string valueName, string plaintext)
{
byte[] plain = Encoding.UTF8.GetBytes(plaintext);
byte[] protectedBytes = ProtectedData.Protect(plain, _dpapiEntropy, DataProtectionScope.LocalMachine);
string b64 = Convert.ToBase64String(protectedBytes);
using var key = Registry.LocalMachine.CreateSubKey(_registryPath, writable: true)
?? throw new InvalidOperationException($"Unable to open/create HKLM\\{_registryPath}");
key.SetValue(valueName, b64, RegistryValueKind.String);
}
private string? ReadProtectedRegistryString(string valueName)
{
using var key = Registry.LocalMachine.OpenSubKey(_registryPath, writable: false);
var b64 = key?.GetValue(valueName) as string;
if (string.IsNullOrWhiteSpace(b64)) return null;
try
{
byte[] protectedBytes = Convert.FromBase64String(b64);
byte[] plain = ProtectedData.Unprotect(protectedBytes, _dpapiEntropy, DataProtectionScope.LocalMachine);
return Encoding.UTF8.GetString(plain);
}
catch
{
return null;
}
}
// ============================================================
// Helpers + DTOs
// ============================================================
private static string NormalizeThumbprint(string thumb)
=> thumb.Replace(" ", "", StringComparison.Ordinal).Trim().ToUpperInvariant();
private static string Sanitize(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "unknown";
var chars = value.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray();
return new string(chars);
}
private sealed class CsrEnrollRequest
{
public string EnrollmentCode { get; set; } = "";
public string? MachineName { get; set; }
public string CsrBase64 { get; set; } = "";
}
private sealed class CsrRenewRequest
{
public string CsrBase64 { get; set; } = "";
}
private sealed class CsrEnrollResponse
{
public string? TenantId { get; set; }
public string? DeviceId { get; set; }
public string? Thumbprint { get; set; }
public DateTime? NotAfterUtc { get; set; }
public string? CertBase64 { get; set; } // DER/PEM-less cert bytes, base64 encoded
}
}
/*
========================================
EXAMPLE USAGE IN .NET 10 WORKER SERVICE
========================================
Worker.cs (snippet)
public sealed class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly IConfiguration _config;
private MtlsEnrollmentClient? _mtls;
public Worker(ILogger<Worker> logger, IConfiguration config)
{
_logger = logger;
_config = config;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_mtls = new MtlsEnrollmentClient(
baseApiUrl: _config["ApiSettings:BaseUrl"]!,
tenantSubjectMarkerFallback: _config["ApiSettings:TenantSubjectMarker"]!, // e.g. OU=tenant_demo_001
registryPath: _config["ApiSettings:RegistryPath"] ?? @"Software\YourCompany\IntegrationAgent\Security",
serviceAccountIdentity: _config["ApiSettings:ServiceAccountIdentity"]); // optional but recommended
await _mtls.EnsureClientCertificateInstalledAsync(_config["ApiSettings:EnrollmentCode"], stoppingToken);
_logger.LogInformation("mTLS ready. Thumbprint={Thumbprint}, DeviceId={DeviceId}",
_mtls.CurrentThumbprint, _mtls.LoadPersistedDeviceId());
while (!stoppingToken.IsCancellationRequested)
{
try
{
bool renewOk = await _mtls.TryRenewCertificateAsync(ct: stoppingToken);
if (!renewOk)
_logger.LogWarning("Renewal check failed.");
using var http = _mtls.CreateMtlsHttpClient();
using var resp = await http.GetAsync("phi/ping", stoppingToken);
var body = await resp.Content.ReadAsStringAsync(stoppingToken);
_logger.LogInformation("phi/ping => {Status} {Body}", (int)resp.StatusCode, body);
}
catch (Exception ex)
{
_logger.LogError(ex, "mTLS call failed");
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment