From 1144df9ce5f540b4cde574ab421bfd7874c56fe2 Mon Sep 17 00:00:00 2001
From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com>
Date: Wed, 27 May 2026 14:28:25 -0700
Subject: [PATCH] [ManagedIdentity] Detect dead KeyGuard keys and purge orphan
IMDSv2 mTLS certs on reboot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes the post-reboot recovery path for IMDSv2 mTLS PoP token acquisition.
On Azure VM restart the per-boot KeyGuard key (NCryptUsePerBootKeyFlag) is
reaped by VBS, but the persisted binding cert under
CN=managedidentitysnissuer.login.microsoft.com still references the old
public key. The next call then either burns a failed TLS handshake before
the reactive SChannel catch kicks in, or — in the zombie-handle variant —
falls through entirely because the cert's modulus still matches the dead
container.
Changes
-------
- Add CanSign liveness probe right after CngKey.Open in
WindowsCngKeyOperations.TryGetOrCreateKeyGuard. 1-byte RSA-SHA256 PKCS1
sign; ~1-3ms, runs once per process (result is cached in
WindowsManagedIdentityKeyProvider._cachedKey). Catches zombie-VBS state
where Open succeeds but private material is dead.
- Add PurgeManagedIdentityCertificates: one-shot issuer-CN substring sweep
of CurrentUser\My, invoked at the moment a fresh KeyGuard key is minted
(both the probe-failed path and the Open-threw path). Removes orphaned
binding certs at the cause site so the next request doesn't pay any
per-Read discovery cost and multi-identity hosts (SAMI + UAMIs sharing
the KeyGuard container) are cleaned up uniformly.
- Add 4 Windows-only unit tests for the purge filter behavior (matching,
non-matching, case-insensitive, only-removes-matching).
The reactive SChannel catch in ImdsV2ManagedIdentitySource is retained as
a defensive backstop.
Validation
----------
Validated E2E on a Server 2022 KeyGuard VM across multiple reboots and
mixed SAMI/UAMI cases. Canonical post-reboot first call:
- CngKey.Open threw CryptographicException HR=0x8009003A
- Fresh KeyGuard key created
- PurgeManagedIdentityCertificates removed orphan cert (Inspected=4)
- MAA attestation OK
- POST /issuecredential -> 200
- mTLS handshake -> 200 on first try (no reactive catch invoked)
- Total ~2.8s on cold start
Full unit suite green on net8.0: 2069 passed, 0 failed, 19 skipped.
Refs #6031.
Complementary to #6020 (cert-side modulus comparison): this PR adds the
key-side liveness probe and broad issuer-CN sweep at the mint site.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../KeyProviders/WindowsCngKeyOperations.cs | 235 +++++++++++-
.../WindowsCngKeyOperationsPurgeUnitTests.cs | 359 ++++++++++++++++++
2 files changed, 592 insertions(+), 2 deletions(-)
create mode 100644 tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WindowsCngKeyOperationsPurgeUnitTests.cs
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs
index d672689d43..aaecc0b66c 100644
--- a/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs
@@ -3,6 +3,7 @@
using System;
using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Internal;
@@ -29,6 +30,12 @@ internal static class WindowsCngKeyOperations
private const string KeyGuardVirtualIsoProperty = "Virtual Iso";
private const string VbsNotAvailable = "VBS key isolation is not available";
+ // Issuer used by IMDSv2 mTLS PoP binding certificates. Matched as a case-insensitive
+ // substring against the certificate's Issuer DN, so any cert in CurrentUser\My issued
+ // by IMDSv2 can be wiped when we mint a fresh KeyGuard key (the previously persisted
+ // certs are bound to the now-replaced key by name and would fail the mTLS handshake).
+ internal const string ManagedIdentityIssuerCnFragment = "managedidentitysnissuer.login.microsoft.com";
+
// KeyGuard + per-boot flags
private const CngKeyCreationOptions NCryptUseVirtualIsolationFlag = (CngKeyCreationOptions)0x00020000;
private const CngKeyCreationOptions NCryptUsePerBootKeyFlag = (CngKeyCreationOptions)0x00040000;
@@ -66,16 +73,75 @@ public static bool TryGetOrCreateKeyGuard(ILoggerAdapter logger, out RSA rsa)
CngKey key;
try
{
+ logger?.Info(() => $"[MI][WinKeyProvider] Attempting to open existing KeyGuard key. " +
+ $"Provider='{SoftwareKspName}', KeyName='{KeyGuardKeyName}', Scope=UserKey, Silent=true.");
+
key = CngKey.Open(
KeyGuardKeyName,
new CngProvider(SoftwareKspName),
CngKeyOpenOptions.UserKey | CngKeyOpenOptions.Silent);
+
+ logger?.Info(() => $"[MI][WinKeyProvider] CngKey.Open succeeded for '{KeyGuardKeyName}'. " +
+ "Running liveness sign probe to detect stale per-boot key material " +
+ "(metadata file can survive a reboot while the VBS-isolated key material is destroyed).");
+
+ // Liveness probe: per-boot KeyGuard keys (NCryptUsePerBootKeyFlag) leave a stale
+ // metadata file on disk after reboot. CngKey.Open returns a handle, but the actual
+ // VBS-protected key material is gone, so the first real sign operation fails.
+ // Detect this here so we can recreate cleanly instead of failing later in the
+ // mTLS handshake or signing path.
+ if (!CanSign(key, logger))
+ {
+ logger?.Info(() => "[MI][WinKeyProvider] KeyGuard liveness sign probe FAILED. " +
+ "Treating handle as stale (likely post-reboot per-boot key reaped). " +
+ "Disposing stale handle and recreating fresh KeyGuard key.");
+ key.Dispose();
+ key = CreateFresh(logger);
+
+ if (key == null)
+ {
+ logger?.Info(() => "[MI][WinKeyProvider] CreateFresh returned null after failed liveness probe " +
+ "(VBS unavailable). KeyGuard path will be skipped.");
+ }
+ else
+ {
+ logger?.Info(() => "[MI][WinKeyProvider] Fresh KeyGuard key created successfully after stale handle replacement. " +
+ "Purging persisted IMDSv2 mTLS binding certificates that were bound to the replaced key.");
+
+ // The new KeyGuard key reuses the container name 'KeyGuardRSAKey', but its
+ // public/private pair is different from the one any persisted cert was issued
+ // against. Wipe all certs in CurrentUser\My issued by IMDSv2 so the next request
+ // mints fresh instead of failing the mTLS handshake.
+ PurgeManagedIdentityCertificates(logger);
+ }
+ }
+ else
+ {
+ logger?.Info(() => "[MI][WinKeyProvider] KeyGuard liveness sign probe PASSED. Reusing existing handle.");
+ }
}
- catch (CryptographicException)
+ catch (CryptographicException openEx)
{
// Not found -> create fresh (helper may return null if VBS unavailable)
- logger?.Info(() => "[MI][WinKeyProvider] CredentialGuard key not found; creating fresh.");
+ logger?.Info(() => $"[MI][WinKeyProvider] CngKey.Open threw CryptographicException for '{KeyGuardKeyName}'. " +
+ $"HR=0x{openEx.HResult:X8}, Message='{openEx.Message}'. " +
+ "Treating as 'key not found' and creating fresh.");
key = CreateFresh(logger);
+
+ if (key == null)
+ {
+ logger?.Info(() => "[MI][WinKeyProvider] CreateFresh returned null after Open failure (VBS unavailable).");
+ }
+ else
+ {
+ logger?.Info(() => "[MI][WinKeyProvider] Fresh KeyGuard key created successfully after Open failure. " +
+ "Purging persisted IMDSv2 mTLS binding certificates that were bound to the replaced key.");
+
+ // Same rationale as the probe-failed branch: any persisted IMDSv2 cert in
+ // CurrentUser\My is bound to the previous KeyGuard key and will fail the mTLS
+ // handshake. Wipe them so the next request mints fresh.
+ PurgeManagedIdentityCertificates(logger);
+ }
}
// If VBS is unavailable, CreateFresh() returns null. Bail out cleanly.
@@ -277,6 +343,171 @@ public static bool IsKeyGuardProtected(CngKey key)
return val?.Length > 0 && val[0] != 0;
}
+ ///
+ /// Performs a small RSA sign operation against the supplied CNG key to verify the
+ /// underlying key material is actually usable.
+ ///
+ /// The CNG key handle returned from .
+ /// Logger for diagnostic output.
+ ///
+ /// if the key signs successfully; otherwise .
+ ///
+ ///
+ ///
+ /// KeyGuard keys created with NCryptUsePerBootKeyFlag have their VBS-isolated
+ /// key material destroyed on every reboot, but the on-disk metadata file produced by the
+ /// Microsoft Software KSP often survives. As a result,
+ /// can return a handle that looks valid (correct algorithm, "Virtual Iso" property still set)
+ /// but whose first real cryptographic operation throws.
+ ///
+ ///
+ /// Probing with a one-byte sign here surfaces that condition cheaply (~1-3 ms for RSA-2048)
+ /// on the cold-start path. Subsequent calls reuse the cached key in
+ /// WindowsManagedIdentityKeyProvider, so the probe runs at most once per process.
+ ///
+ ///
+ private static bool CanSign(CngKey key, ILoggerAdapter logger)
+ {
+ try
+ {
+ logger?.Verbose(() => "[MI][WinKeyProvider] Liveness probe: attempting RSA-SHA256 sign of 1-byte payload.");
+
+ using (var rsa = new RSACng(key))
+ {
+ _ = rsa.SignData(
+ new byte[] { 0 },
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ }
+
+ logger?.Verbose(() => "[MI][WinKeyProvider] Liveness probe: sign succeeded; key material is live.");
+ return true;
+ }
+ catch (CryptographicException ex)
+ {
+ logger?.Info(() => $"[MI][WinKeyProvider] Liveness probe: sign threw CryptographicException. " +
+ $"HR=0x{ex.HResult:X8}, Message='{ex.Message}'. Key handle is stale.");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ logger?.Info(() => $"[MI][WinKeyProvider] Liveness probe: sign threw unexpected exception. " +
+ $"{ex.GetType().Name}: '{ex.Message}'. Treating as stale.");
+ return false;
+ }
+ }
+
+ ///
+ /// Deletes every certificate in the CurrentUser\My store whose issuer matches the
+ /// IMDSv2 mTLS PoP binding-certificate issuer.
+ ///
+ /// Logger for diagnostic output.
+ ///
+ ///
+ /// IMDSv2 binding certificates are issued by
+ /// CN=managedidentitysnissuer.login.microsoft.com and stored in the user's personal
+ /// store. They reference the private key by KSP container name (KeyGuardRSAKey),
+ /// not by key material. When the KeyGuard key is re-minted (post-reboot, or after a failed
+ /// liveness probe), the new key reuses the same container name but with different
+ /// public/private parameters — leaving the persisted certs bound to a key that no longer
+ /// matches them, which then fails the mTLS handshake.
+ ///
+ ///
+ /// Purging the store at the moment we mint a fresh KeyGuard key eliminates the
+ /// failed-handshake + retry round trip that the SChannel-error catch in
+ /// ImdsV2ManagedIdentitySource.AuthenticateAsync would otherwise have to recover from.
+ ///
+ ///
+ /// All store I/O is best-effort and non-throwing.
+ ///
+ ///
+ internal static void PurgeManagedIdentityCertificates(ILoggerAdapter logger)
+ {
+ int removed = 0;
+ int inspected = 0;
+
+ try
+ {
+ logger?.Info(() =>
+ $"[MI][WinKeyProvider] PurgeManagedIdentityCertificates: opening CurrentUser\\My to remove " +
+ $"certs whose Issuer contains '{ManagedIdentityIssuerCnFragment}'.");
+
+ using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
+ {
+ store.Open(OpenFlags.ReadWrite);
+
+ // Snapshot to avoid 'collection modified during enumeration' provider quirks.
+ var snapshot = new X509Certificate2[store.Certificates.Count];
+ try
+ {
+ store.Certificates.CopyTo(snapshot, 0);
+ }
+ catch (Exception copyEx)
+ {
+ logger?.Info(() =>
+ $"[MI][WinKeyProvider] PurgeManagedIdentityCertificates: store snapshot via CopyTo failed " +
+ $"({copyEx.GetType().Name}: {copyEx.Message}). Falling back to enumeration.");
+
+ int i = 0;
+ snapshot = new X509Certificate2[store.Certificates.Count];
+ foreach (X509Certificate2 c in store.Certificates)
+ {
+ snapshot[i++] = c;
+ }
+ }
+
+ foreach (X509Certificate2 candidate in snapshot)
+ {
+ try
+ {
+ inspected++;
+
+ string issuer = candidate.Issuer ?? string.Empty;
+ if (issuer.IndexOf(ManagedIdentityIssuerCnFragment, StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ continue;
+ }
+
+ string thumb = candidate.Thumbprint;
+ DateTime notAfter = candidate.NotAfter;
+
+ try
+ {
+ store.Remove(candidate);
+ removed++;
+ logger?.Info(() =>
+ $"[MI][WinKeyProvider] PurgeManagedIdentityCertificates: removed cert. " +
+ $"Thumbprint={thumb}, NotAfter={notAfter:O}, Issuer='{issuer}'.");
+ }
+ catch (Exception removeEx)
+ {
+ logger?.Info(() =>
+ $"[MI][WinKeyProvider] PurgeManagedIdentityCertificates: failed to remove cert " +
+ $"Thumbprint={thumb}. {removeEx.GetType().Name}: '{removeEx.Message}'.");
+ }
+ }
+ finally
+ {
+ candidate.Dispose();
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.Info(() =>
+ $"[MI][WinKeyProvider] PurgeManagedIdentityCertificates: store access failed. " +
+ $"{ex.GetType().Name}: '{ex.Message}'. Removed={removed}, Inspected={inspected}.");
+ return;
+ }
+
+ int removedFinal = removed;
+ int inspectedFinal = inspected;
+ logger?.Info(() =>
+ $"[MI][WinKeyProvider] PurgeManagedIdentityCertificates: complete. " +
+ $"Removed={removedFinal}, Inspected={inspectedFinal}.");
+ }
+
///
/// Determines whether a cryptographic exception indicates that VBS is unavailable.
///
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WindowsCngKeyOperationsPurgeUnitTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WindowsCngKeyOperationsPurgeUnitTests.cs
new file mode 100644
index 0000000000..93340f9978
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WindowsCngKeyOperationsPurgeUnitTests.cs
@@ -0,0 +1,359 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Identity.Client.Core;
+using Microsoft.Identity.Client.ManagedIdentity.KeyProviders;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute;
+
+namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests
+{
+ ///
+ /// Tests for .
+ /// The purge sweeps CurrentUser\My and removes every certificate whose issuer
+ /// contains .
+ /// It runs after a fresh KeyGuard key is minted so that persisted IMDSv2 binding
+ /// certs (which are bound by container name to the now-replaced key) are not left
+ /// behind to fail the next mTLS handshake.
+ ///
+ [TestClass]
+ public class WindowsCngKeyOperationsPurgeUnitTests
+ {
+ // Discriminator we plant in each test cert's Subject so cleanup can find leftovers
+ // from a failed run without touching unrelated certs in the developer's store.
+ private const string TestSubjectDiscriminatorPrefix = "MSAL-Purge-Test-";
+
+ private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ private static ILoggerAdapter Logger => Substitute.For();
+
+ [TestInitialize]
+ public void Init()
+ {
+ // Reuse the existing broad sweep so prior test runs don't leak state.
+ if (ImdsV2TestStoreCleaner.IsWindows)
+ {
+ ImdsV2TestStoreCleaner.RemoveAllTestArtifacts();
+ }
+
+ // Also remove any leftover purge-test certs from a previous failed run.
+ RemoveAllPurgeTestArtifacts();
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ RemoveAllPurgeTestArtifacts();
+ }
+
+ private static void WindowsOnly()
+ {
+ if (!IsWindows)
+ {
+ Assert.Inconclusive("Windows-only");
+ }
+ }
+
+ [TestMethod]
+ public void PurgeManagedIdentityCertificates_RemovesCertWithMatchingIssuer()
+ {
+ WindowsOnly();
+
+ // Arrange
+ string discriminator = TestSubjectDiscriminatorPrefix + Guid.NewGuid().ToString("N");
+ string subject =
+ "CN=" + WindowsCngKeyOperations.ManagedIdentityIssuerCnFragment +
+ ", OU=" + discriminator;
+
+ string plantedThumbprint;
+ using (var cert = CreateSelfSignedWithKey(subject, TimeSpan.FromDays(2)))
+ {
+ plantedThumbprint = cert.Thumbprint;
+ AddToCurrentUserMyStore(cert);
+ }
+
+ Assert.IsTrue(
+ IsInCurrentUserMyStore(plantedThumbprint),
+ "Test setup precondition: planted cert must be present in CurrentUser\\My before purge.");
+
+ // Act
+ WindowsCngKeyOperations.PurgeManagedIdentityCertificates(Logger);
+
+ // Assert
+ Assert.IsFalse(
+ IsInCurrentUserMyStore(plantedThumbprint),
+ "Purge should remove certs whose Issuer contains the managed identity issuer CN.");
+ }
+
+ [TestMethod]
+ public void PurgeManagedIdentityCertificates_LeavesCertWithNonMatchingIssuer()
+ {
+ WindowsOnly();
+
+ // Arrange
+ string discriminator = TestSubjectDiscriminatorPrefix + Guid.NewGuid().ToString("N");
+ // Subject/Issuer that does NOT contain the managed identity issuer fragment.
+ string subject = "CN=unrelated.example.test, OU=" + discriminator;
+
+ string plantedThumbprint;
+ using (var cert = CreateSelfSignedWithKey(subject, TimeSpan.FromDays(2)))
+ {
+ plantedThumbprint = cert.Thumbprint;
+ AddToCurrentUserMyStore(cert);
+ }
+
+ Assert.IsTrue(
+ IsInCurrentUserMyStore(plantedThumbprint),
+ "Test setup precondition: planted cert must be present in CurrentUser\\My before purge.");
+
+ try
+ {
+ // Act
+ WindowsCngKeyOperations.PurgeManagedIdentityCertificates(Logger);
+
+ // Assert
+ Assert.IsTrue(
+ IsInCurrentUserMyStore(plantedThumbprint),
+ "Purge must not remove certs whose Issuer does not contain the managed identity issuer CN.");
+ }
+ finally
+ {
+ RemoveByThumbprintFromCurrentUserMyStore(plantedThumbprint);
+ }
+ }
+
+ [TestMethod]
+ public void PurgeManagedIdentityCertificates_MatchIsCaseInsensitive()
+ {
+ WindowsOnly();
+
+ // Arrange
+ string discriminator = TestSubjectDiscriminatorPrefix + Guid.NewGuid().ToString("N");
+ // Uppercase the issuer fragment to ensure the match is OrdinalIgnoreCase.
+ string subject =
+ "CN=" + WindowsCngKeyOperations.ManagedIdentityIssuerCnFragment.ToUpperInvariant() +
+ ", OU=" + discriminator;
+
+ string plantedThumbprint;
+ using (var cert = CreateSelfSignedWithKey(subject, TimeSpan.FromDays(2)))
+ {
+ plantedThumbprint = cert.Thumbprint;
+ AddToCurrentUserMyStore(cert);
+ }
+
+ Assert.IsTrue(
+ IsInCurrentUserMyStore(plantedThumbprint),
+ "Test setup precondition: planted cert must be present in CurrentUser\\My before purge.");
+
+ // Act
+ WindowsCngKeyOperations.PurgeManagedIdentityCertificates(Logger);
+
+ // Assert
+ Assert.IsFalse(
+ IsInCurrentUserMyStore(plantedThumbprint),
+ "Purge issuer match should be case-insensitive.");
+ }
+
+ [TestMethod]
+ public void PurgeManagedIdentityCertificates_OnlyRemovesMatching_LeavesOtherCertsAlone()
+ {
+ WindowsOnly();
+
+ // Arrange: plant one matching and one non-matching cert
+ string matchDiscriminator = TestSubjectDiscriminatorPrefix + Guid.NewGuid().ToString("N");
+ string nonMatchDiscriminator = TestSubjectDiscriminatorPrefix + Guid.NewGuid().ToString("N");
+
+ string matchingSubject =
+ "CN=" + WindowsCngKeyOperations.ManagedIdentityIssuerCnFragment +
+ ", OU=" + matchDiscriminator;
+ string nonMatchingSubject = "CN=unrelated.example.test, OU=" + nonMatchDiscriminator;
+
+ string matchingThumb;
+ string nonMatchingThumb;
+
+ using (var matching = CreateSelfSignedWithKey(matchingSubject, TimeSpan.FromDays(2)))
+ using (var nonMatching = CreateSelfSignedWithKey(nonMatchingSubject, TimeSpan.FromDays(2)))
+ {
+ matchingThumb = matching.Thumbprint;
+ nonMatchingThumb = nonMatching.Thumbprint;
+
+ AddToCurrentUserMyStore(matching);
+ AddToCurrentUserMyStore(nonMatching);
+ }
+
+ Assert.IsTrue(IsInCurrentUserMyStore(matchingThumb), "Matching cert must be planted.");
+ Assert.IsTrue(IsInCurrentUserMyStore(nonMatchingThumb), "Non-matching cert must be planted.");
+
+ try
+ {
+ // Act
+ WindowsCngKeyOperations.PurgeManagedIdentityCertificates(Logger);
+
+ // Assert
+ Assert.IsFalse(IsInCurrentUserMyStore(matchingThumb), "Matching cert should be purged.");
+ Assert.IsTrue(IsInCurrentUserMyStore(nonMatchingThumb), "Non-matching cert should survive.");
+ }
+ finally
+ {
+ RemoveByThumbprintFromCurrentUserMyStore(nonMatchingThumb);
+ }
+ }
+
+ // ---------------- helpers ----------------
+
+ ///
+ /// Creates a self-signed RSA cert with a persistable private key.
+ /// Mirrors the pattern used by PersistentCertificateStoreUnitTests.CreateSelfSignedWithKey.
+ ///
+ private static X509Certificate2 CreateSelfSignedWithKey(string subject, TimeSpan lifetime)
+ {
+ using var rsa = RSA.Create(2048);
+
+ var req = new CertificateRequest(
+ new X500DistinguishedName(subject),
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-2);
+ DateTimeOffset notAfter = notBefore.Add(lifetime);
+
+ using var ephemeral = req.CreateSelfSigned(notBefore, notAfter);
+
+ // Re-import as PFX so the private key is persisted and the store will accept it.
+ var pfx = ephemeral.Export(X509ContentType.Pfx, "");
+ return new X509Certificate2(
+ pfx,
+ "",
+ X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
+ }
+
+ private static void AddToCurrentUserMyStore(X509Certificate2 cert)
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+ store.Add(cert);
+ }
+
+ private static bool IsInCurrentUserMyStore(string thumbprint)
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
+
+ foreach (X509Certificate2 c in store.Certificates)
+ {
+ try
+ {
+ if (string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ finally
+ {
+ c.Dispose();
+ }
+ }
+
+ return false;
+ }
+
+ private static void RemoveByThumbprintFromCurrentUserMyStore(string thumbprint)
+ {
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+
+ X509Certificate2[] snapshot;
+ try
+ {
+ snapshot = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(snapshot, 0);
+ }
+ catch
+ {
+ snapshot = store.Certificates.Cast().ToArray();
+ }
+
+ foreach (var c in snapshot)
+ {
+ try
+ {
+ if (string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ { store.Remove(c); }
+ catch { /* best-effort */ }
+ }
+ }
+ finally
+ {
+ c.Dispose();
+ }
+ }
+ }
+ catch
+ {
+ // best-effort cleanup
+ }
+ }
+
+ ///
+ /// Removes any leftover purge-test certificates from CurrentUser\My.
+ /// Matches our unique Subject OU discriminator to avoid touching unrelated certs.
+ /// Best-effort, no-throw.
+ ///
+ private static void RemoveAllPurgeTestArtifacts()
+ {
+ if (!IsWindows)
+ {
+ return;
+ }
+
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+
+ X509Certificate2[] snapshot;
+ try
+ {
+ snapshot = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(snapshot, 0);
+ }
+ catch
+ {
+ snapshot = store.Certificates.Cast().ToArray();
+ }
+
+ foreach (var c in snapshot)
+ {
+ try
+ {
+ string subject = c.Subject ?? string.Empty;
+ if (subject.IndexOf(TestSubjectDiscriminatorPrefix, StringComparison.Ordinal) >= 0)
+ {
+ try
+ { store.Remove(c); }
+ catch { /* best-effort */ }
+ }
+ }
+ finally
+ {
+ c.Dispose();
+ }
+ }
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+ }
+}