Skip to content

Instantly share code, notes, and snippets.

@swaters86
Created February 20, 2026 19:58
Show Gist options
  • Select an option

  • Save swaters86/68ba298bf73258842fdc9af49c959ecf to your computer and use it in GitHub Desktop.

Select an option

Save swaters86/68ba298bf73258842fdc9af49c959ecf to your computer and use it in GitHub Desktop.
// MtlsEnrollmentClient.cs - .NET Framework 4.8 / C# 7.3
// Drop into your Windows Service project.
// NO DataContract/DataMember required.
//
// References needed (Framework built-ins):
// - System.Net.Http
// - System.Web.Extensions (for JavaScriptSerializer)
// - System.Security
//
// Implements:
// - EnsureClientCertificateInstalledAsync(enrollmentCode) -> installs client cert if missing
// - CreateMtlsHttpClient() -> HttpClient presenting client cert (mTLS)
// - TryRenewCertificateAsync() -> rotate cert using /cert/renew
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization; // JavaScriptSerializer (System.Web.Extensions)
public sealed class MtlsEnrollmentClient
{
private readonly Uri _baseApi;
private readonly string _certSubjectContains;
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(30);
private static readonly JavaScriptSerializer Json = new JavaScriptSerializer();
public string CurrentThumbprint { get; private set; }
// certSubjectContains: something stable you embed in cert subject, e.g. "OU=tenant_demo_001"
public MtlsEnrollmentClient(string baseApiUrl, string certSubjectContains)
{
if (string.IsNullOrWhiteSpace(baseApiUrl)) throw new ArgumentNullException("baseApiUrl");
if (string.IsNullOrWhiteSpace(certSubjectContains)) throw new ArgumentNullException("certSubjectContains");
_baseApi = new Uri(baseApiUrl.TrimEnd('/') + "/");
_certSubjectContains = certSubjectContains;
}
public async Task EnsureClientCertificateInstalledAsync(string enrollmentCode, CancellationToken ct = default(CancellationToken))
{
// Force TLS 1.2 in .NET Framework
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
var existing = FindExistingClientCert();
if (existing != null)
{
CurrentThumbprint = existing.Thumbprint;
return;
}
if (string.IsNullOrWhiteSpace(enrollmentCode))
throw new InvalidOperationException("No client certificate installed and enrollmentCode was not provided.");
var req = new
{
enrollmentCode = enrollmentCode.Trim(),
machineName = Environment.MachineName
};
var enrollResp = await PostJsonAsync<EnrollResponse>(new Uri(_baseApi, "enroll"), req, handler: CreateHandlerNoClientCert(), ct: ct)
.ConfigureAwait(false);
if (enrollResp == null || string.IsNullOrWhiteSpace(enrollResp.pfxBase64))
throw new InvalidOperationException("Enrollment failed: empty response.");
var pfxBytes = Convert.FromBase64String(enrollResp.pfxBase64);
InstallPfxToLocalMachineMy(pfxBytes, enrollResp.pfxPassword ?? "");
var installed = FindExistingClientCert();
if (installed == null)
throw new InvalidOperationException("Enrollment succeeded but certificate was not found in LocalMachine\\My.");
CurrentThumbprint = installed.Thumbprint;
}
public HttpClient CreateMtlsHttpClient()
{
var cert = FindExistingClientCert();
if (cert == null)
throw new InvalidOperationException("Client certificate not installed. Call EnsureClientCertificateInstalledAsync first.");
CurrentThumbprint = cert.Thumbprint;
var handler = new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12,
ClientCertificateOptions = ClientCertificateOption.Manual
};
handler.ClientCertificates.Add(cert);
// DO NOT disable server cert validation in production.
return new HttpClient(handler)
{
BaseAddress = _baseApi,
Timeout = _timeout
};
}
public async Task<bool> TryRenewCertificateAsync(CancellationToken ct = default(CancellationToken))
{
var cert = FindExistingClientCert();
if (cert == null) return false;
// Renew when within 14 days of expiry
if (cert.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddDays(14))
return true;
using (var http = CreateMtlsHttpClient())
{
using (var resp = await http.PostAsync("cert/renew", new StringContent("{}", Encoding.UTF8, "application/json"), ct).ConfigureAwait(false))
{
if (!resp.IsSuccessStatusCode) return false;
var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
var renewResp = Json.Deserialize<EnrollResponse>(json);
if (renewResp == null || string.IsNullOrWhiteSpace(renewResp.pfxBase64))
return false;
InstallPfxToLocalMachineMy(Convert.FromBase64String(renewResp.pfxBase64), renewResp.pfxPassword ?? "");
var newCert = FindExistingClientCert();
if (newCert != null) CurrentThumbprint = newCert.Thumbprint;
return true;
}
}
}
// ---------------- internals ----------------
private HttpClientHandler CreateHandlerNoClientCert()
{
return new HttpClientHandler { SslProtocols = SslProtocols.Tls12 };
}
private X509Certificate2 FindExistingClientCert()
{
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
{
store.Open(OpenFlags.ReadOnly);
// Find newest valid cert matching our subject marker
return store.Certificates
.OfType<X509Certificate2>()
.Where(c =>
c.HasPrivateKey &&
!string.IsNullOrWhiteSpace(c.Subject) &&
c.Subject.IndexOf(_certSubjectContains, StringComparison.OrdinalIgnoreCase) >= 0 &&
c.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddMinutes(5))
.OrderByDescending(c => c.NotAfter)
.FirstOrDefault();
}
}
private void InstallPfxToLocalMachineMy(byte[] pfxBytes, string password)
{
// NOTE: Not exportable (do NOT add Exportable).
var cert = new X509Certificate2(
pfxBytes,
password,
X509KeyStorageFlags.MachineKeySet |
X509KeyStorageFlags.PersistKeySet
);
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
{
store.Open(OpenFlags.ReadWrite);
store.Add(cert);
}
}
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 })
{
var json = Json.Serialize(body);
using (var content = new StringContent(json, Encoding.UTF8, "application/json"))
using (var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false))
{
var respText = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException("HTTP " + (int)resp.StatusCode + " " + resp.ReasonPhrase + " :: " + respText);
return Json.Deserialize<T>(respText);
}
}
}
// Matches the API JSON names (camelCase)
private sealed class EnrollResponse
{
public string tenantId { get; set; }
public string thumbprint { get; set; }
public string notAfterUtc { get; set; } // string is fine in .NET Framework
public string pfxBase64 { get; set; }
public string pfxPassword { get; set; }
}
}
// ---------------- Example usage ----------------
//
// var mtls = new MtlsEnrollmentClient(
// baseApiUrl: "https://api.yourdomain.com/",
// certSubjectContains: "OU=tenant_demo_001" // must match what API issues (OU=tenantId in Program.cs above)
// );
//
// await mtls.EnsureClientCertificateInstalledAsync(enrollmentCodeFromInstaller);
// using (var http = mtls.CreateMtlsHttpClient())
// {
// var r = await http.GetAsync("phi/ping");
// var body = await r.Content.ReadAsStringAsync();
// }
//
// await mtls.TryRenewCertificateAsync();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment