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 + } + } + } +}