Created
February 20, 2026 19:43
-
-
Save swaters86/8a3f5e01cb909acc0b09305728221efa to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // MtlsEnrollmentClient.cs - .NET Framework 4.8 / C# 7.3 | |
| // Drop into your Windows Service project. | |
| // Implements: | |
| // - EnsureClientCertificateInstalledAsync(enrollmentCode) -> installs client cert if missing | |
| // - CreateMtlsHttpClient() -> HttpClient that automatically presents the client cert (mTLS) | |
| // - TryRenewCertificateAsync() -> rotate cert using /cert/renew | |
| // | |
| // Storage: | |
| // - Certificate is stored in LocalMachine\My and private key is marked non-exportable when imported. | |
| // - You should run your service under a dedicated service account and lock down private key ACLs. | |
| // | |
| // SECURITY NOTE: | |
| // - This example enrolls by downloading a PFX from your API over TLS. | |
| // - For maximum security, replace enrollment with CSR-based issuance (private key generated locally). | |
| // | |
| // Requires references: System.Net.Http, System.Security, System.Runtime.Serialization (for DataContractJsonSerializer) | |
| using System; | |
| using System.IO; | |
| using System.Linq; | |
| using System.Net; | |
| using System.Net.Http; | |
| using System.Runtime.Serialization; | |
| using System.Runtime.Serialization.Json; | |
| using System.Security.Authentication; | |
| using System.Security.Cryptography.X509Certificates; | |
| using System.Text; | |
| using System.Threading; | |
| using System.Threading.Tasks; | |
| public sealed class MtlsEnrollmentClient | |
| { | |
| private readonly Uri _baseApi; | |
| private readonly string _certSubjectPrefix; // helps find our cert if thumbprint not known | |
| private readonly string _tenantLabel; // optional label for debugging | |
| private readonly TimeSpan _timeout = TimeSpan.FromSeconds(30); | |
| // You can store thumbprint in Registry/Config after first enrollment. | |
| // For simplicity, this helper can locate cert by subject prefix as a fallback. | |
| public string CurrentThumbprint { get; private set; } | |
| public MtlsEnrollmentClient(string baseApiUrl, string certSubjectPrefix, string tenantLabel = null) | |
| { | |
| if (string.IsNullOrWhiteSpace(baseApiUrl)) throw new ArgumentNullException("baseApiUrl"); | |
| if (string.IsNullOrWhiteSpace(certSubjectPrefix)) throw new ArgumentNullException("certSubjectPrefix"); | |
| _baseApi = new Uri(baseApiUrl.TrimEnd('/') + "/"); | |
| _certSubjectPrefix = certSubjectPrefix; // example: "CN=client-tenant_demo_001" | |
| _tenantLabel = tenantLabel ?? "unknown"; | |
| } | |
| // -------------------- Public API -------------------- | |
| // Call this once at startup (OnStart) to ensure cert exists. | |
| public async Task EnsureClientCertificateInstalledAsync(string enrollmentCode, CancellationToken ct = default(CancellationToken)) | |
| { | |
| // Ensure TLS 1.2 on older Windows/.NET settings | |
| ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; | |
| // 1) Try to locate existing cert | |
| 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."); | |
| // 2) Enroll: request PFX | |
| var enrollReq = new EnrollRequest | |
| { | |
| EnrollmentCode = enrollmentCode.Trim(), | |
| MachineName = Environment.MachineName | |
| }; | |
| var enrollResp = await PostJsonAsync<EnrollRequest, EnrollResponse>( | |
| new Uri(_baseApi, "enroll"), | |
| enrollReq, | |
| handler: CreateDefaultHandlerNoClientCert(), | |
| ct: ct).ConfigureAwait(false); | |
| if (enrollResp == null || string.IsNullOrWhiteSpace(enrollResp.PfxBase64)) | |
| throw new InvalidOperationException("Enrollment failed: empty response."); | |
| // 3) Install cert to LocalMachine\My with non-exportable private key | |
| var pfxBytes = Convert.FromBase64String(enrollResp.PfxBase64); | |
| var pfxPassword = enrollResp.PfxPassword ?? ""; | |
| InstallPfxToLocalMachineMy(pfxBytes, pfxPassword); | |
| // 4) Confirm installed | |
| var installed = FindExistingClientCert(); | |
| if (installed == null) | |
| throw new InvalidOperationException("Enrollment succeeded but certificate was not found in LocalMachine\\My."); | |
| CurrentThumbprint = installed.Thumbprint; | |
| } | |
| // Use this for all service->API calls after enrollment | |
| 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); | |
| // IMPORTANT: Do NOT disable server certificate validation in production. | |
| // handler.ServerCertificateCustomValidationCallback = (m, c, ch, e) => true; // <-- DON’T DO THIS. | |
| var client = new HttpClient(handler) | |
| { | |
| BaseAddress = _baseApi, | |
| Timeout = _timeout | |
| }; | |
| // Example default headers (optional) | |
| client.DefaultRequestHeaders.UserAgent.ParseAdd("YourService/1.0"); | |
| return client; | |
| } | |
| // Call periodically (e.g., daily) or when cert is near expiry. | |
| public async Task<bool> TryRenewCertificateAsync(CancellationToken ct = default(CancellationToken)) | |
| { | |
| var cert = FindExistingClientCert(); | |
| if (cert == null) return false; | |
| // If not close to expiry, you can skip renewal (example threshold: 14 days) | |
| if (cert.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddDays(14)) | |
| return true; | |
| using (var mtlsClient = CreateMtlsHttpClient()) | |
| { | |
| var resp = await mtlsClient.PostAsync("cert/renew", new StringContent("{}", Encoding.UTF8, "application/json"), ct) | |
| .ConfigureAwait(false); | |
| if (!resp.IsSuccessStatusCode) | |
| return false; | |
| var enrollResp = DeserializeJson<EnrollResponse>(await resp.Content.ReadAsStreamAsync().ConfigureAwait(false)); | |
| if (enrollResp == null || string.IsNullOrWhiteSpace(enrollResp.PfxBase64)) | |
| return false; | |
| InstallPfxToLocalMachineMy(Convert.FromBase64String(enrollResp.PfxBase64), enrollResp.PfxPassword ?? ""); | |
| // Optional: you may also remove the old cert if you track its thumbprint. | |
| // In many ops setups, leaving old certs until cleanup is acceptable. | |
| // Update thumbprint | |
| var newCert = FindExistingClientCert(); | |
| if (newCert != null) CurrentThumbprint = newCert.Thumbprint; | |
| return true; | |
| } | |
| } | |
| // -------------------- Internals -------------------- | |
| private HttpClientHandler CreateDefaultHandlerNoClientCert() | |
| { | |
| return new HttpClientHandler | |
| { | |
| SslProtocols = SslProtocols.Tls12 | |
| }; | |
| } | |
| private X509Certificate2 FindExistingClientCert() | |
| { | |
| // Search LocalMachine\My for a cert whose subject starts with our prefix and has a private key | |
| using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) | |
| { | |
| store.Open(OpenFlags.ReadOnly); | |
| var candidates = store.Certificates | |
| .OfType<X509Certificate2>() | |
| .Where(c => | |
| c.HasPrivateKey && | |
| c.Subject != null && | |
| c.Subject.IndexOf(_certSubjectPrefix, StringComparison.OrdinalIgnoreCase) >= 0 && | |
| c.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddMinutes(5)) | |
| .OrderByDescending(c => c.NotAfter) | |
| .ToList(); | |
| return candidates.FirstOrDefault(); | |
| } | |
| } | |
| private void InstallPfxToLocalMachineMy(byte[] pfxBytes, string password) | |
| { | |
| // MachineKeySet: store private key for LocalMachine | |
| // PersistKeySet: keep it after object disposed | |
| // (Do NOT use Exportable unless you explicitly need it.) | |
| 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<TResp> PostJsonAsync<TReq, TResp>(Uri url, TReq body, HttpClientHandler handler, CancellationToken ct) | |
| where TReq : class | |
| where TResp : class | |
| { | |
| using (var client = new HttpClient(handler) { Timeout = _timeout }) | |
| { | |
| var json = SerializeJson(body); | |
| using (var content = new StringContent(json, Encoding.UTF8, "application/json")) | |
| using (var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false)) | |
| { | |
| if (!resp.IsSuccessStatusCode) | |
| { | |
| var msg = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); | |
| throw new InvalidOperationException("HTTP " + (int)resp.StatusCode + " " + resp.ReasonPhrase + " :: " + msg); | |
| } | |
| var stream = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false); | |
| return DeserializeJson<TResp>(stream); | |
| } | |
| } | |
| } | |
| private static string SerializeJson<T>(T obj) | |
| { | |
| var ser = new DataContractJsonSerializer(typeof(T)); | |
| using (var ms = new MemoryStream()) | |
| { | |
| ser.WriteObject(ms, obj); | |
| return Encoding.UTF8.GetString(ms.ToArray()); | |
| } | |
| } | |
| private static T DeserializeJson<T>(Stream stream) where T : class | |
| { | |
| var ser = new DataContractJsonSerializer(typeof(T)); | |
| return ser.ReadObject(stream) as T; | |
| } | |
| // -------------------- DTOs -------------------- | |
| [DataContract] | |
| private sealed class EnrollRequest | |
| { | |
| [DataMember(Name = "enrollmentCode", IsRequired = true)] | |
| public string EnrollmentCode { get; set; } | |
| [DataMember(Name = "machineName", IsRequired = false)] | |
| public string MachineName { get; set; } | |
| } | |
| [DataContract] | |
| private sealed class EnrollResponse | |
| { | |
| [DataMember(Name = "tenantId", IsRequired = false)] | |
| public string TenantId { get; set; } | |
| [DataMember(Name = "thumbprint", IsRequired = false)] | |
| public string Thumbprint { get; set; } | |
| [DataMember(Name = "notAfterUtc", IsRequired = false)] | |
| public DateTime NotAfterUtc { get; set; } | |
| [DataMember(Name = "pfxBase64", IsRequired = true)] | |
| public string PfxBase64 { get; set; } | |
| [DataMember(Name = "pfxPassword", IsRequired = true)] | |
| public string PfxPassword { get; set; } | |
| } | |
| } | |
| // -------------------- Example usage in a Windows Service -------------------- | |
| // In your service: | |
| // 1) Call EnsureClientCertificateInstalledAsync(enrollmentCode) at startup | |
| // 2) Use CreateMtlsHttpClient() for all API calls | |
| // | |
| // Example (pseudo in OnStart): | |
| // | |
| // var mtls = new MtlsEnrollmentClient( | |
| // baseApiUrl: "https://api.yourdomain.com/", | |
| // certSubjectPrefix: "CN=client-tenant_demo_001" // match whatever your API uses for subject | |
| // ); | |
| // | |
| // await mtls.EnsureClientCertificateInstalledAsync(enrollmentCodeFromInstaller); | |
| // using (var http = mtls.CreateMtlsHttpClient()) | |
| // { | |
| // var r = await http.GetAsync("phi/ping"); | |
| // var body = await r.Content.ReadAsStringAsync(); | |
| // // log body | |
| // } | |
| // | |
| // Optional daily renewal: | |
| // await mtls.TryRenewCertificateAsync(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment