Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -277,6 +343,178 @@ public static bool IsKeyGuardProtected(CngKey key)
return val?.Length > 0 && val[0] != 0;
}

/// <summary>
/// Performs a small RSA sign operation against the supplied CNG key to verify the
/// underlying key material is actually usable.
/// </summary>
/// <param name="key">The CNG key handle returned from <see cref="CngKey.Open(string, CngProvider, CngKeyOpenOptions)"/>.</param>
/// <param name="logger">Logger for diagnostic output.</param>
/// <returns>
/// <see langword="true"/> if the key signs successfully; otherwise <see langword="false"/>.
/// </returns>
/// <remarks>
/// <para>
/// KeyGuard keys created with <c>NCryptUsePerBootKeyFlag</c> 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, <see cref="CngKey.Open(string, CngProvider, CngKeyOpenOptions)"/>
/// can return a handle that looks valid (correct algorithm, "Virtual Iso" property still set)
/// but whose first real cryptographic operation throws.
/// </para>
/// <para>
/// 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
/// <c>WindowsManagedIdentityKeyProvider</c>, so the probe runs at most once per process.
/// </para>
/// </remarks>
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;
}
}

/// <summary>
/// Deletes every certificate in the <c>CurrentUser\My</c> store whose issuer matches the
/// IMDSv2 mTLS PoP binding-certificate issuer.
/// </summary>
/// <param name="logger">Logger for diagnostic output.</param>
/// <remarks>
/// <para>
/// IMDSv2 binding certificates are issued by
/// <c>CN=managedidentitysnissuer.login.microsoft.com</c> and stored in the user's personal
/// store. They reference the private key by KSP container name (<c>KeyGuardRSAKey</c>),
/// 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.
/// </para>
/// <para>
/// 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
/// <c>ImdsV2ManagedIdentitySource.AuthenticateAsync</c> would otherwise have to recover from.
/// </para>
/// <para>
/// All store I/O is best-effort and non-throwing.
/// </para>
/// </remarks>
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)
{
if (candidate is null)
{
// Defensive: snapshot slot may be null if the store enumeration
// yielded fewer items than Certificates.Count reported (TOCTOU).
continue;
}

try
{
inspected++;

string issuer = candidate.Issuer ?? string.Empty;
if (issuer.IndexOf(ManagedIdentityIssuerCnFragment, StringComparison.OrdinalIgnoreCase) < 0)
{
Comment on lines +439 to +474
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 33eba20. Added a if (candidate is null) continue; guard at the top of the consuming foreach to defend against the TOCTOU window between store.Certificates.Count and the actual enumeration in the CopyTo-failed fallback path.

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}.");
}

/// <summary>
/// Determines whether a cryptographic exception indicates that VBS is unavailable.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -185,5 +186,87 @@ public void RemoveBadCert(string cacheKey, ILoggerAdapter logger)
logger?.Verbose(() => $"[PersistentCert] Error removing from persistent cache: {ex.Message}");
}
}

/// <summary>
/// Returns <see langword="true"/> if the cert's embedded public key does not match the
/// public key currently in the associated CNG container, indicating the container was
/// regenerated (e.g. by KeyGuard on reboot) while the cert on disk still references the
/// old key material.
/// </summary>
internal static bool IsCertKeyOrphaned(X509Certificate2 cert, ILoggerAdapter logger)
{
if (cert is null)
return true;

try
{
using var rsaKey = cert.GetRSAPrivateKey();
if (rsaKey is null)
{
// GetRSAPrivateKey() returns null for non-RSA certs (e.g. ECDSA) AND for RSA
// certs where the private key is inaccessible. Distinguish the two cases:
// if the cert has an RSA public key, the private key should be present but isn't
// → the cert is unusable. If there is no RSA public key, it's a non-RSA cert
// that we can't check → accept on faith.
using var pubKey = cert.GetRSAPublicKey();
return pubKey is not null; // RSA cert + inaccessible private key = orphaned
}

if (rsaKey is not RSACng rsaCng)
{
// Non-CNG RSA key (e.g. software CSP) — cannot perform KG container check; accept.
return false;
}
Comment thread
Robbie-Microsoft marked this conversation as resolved.

return !PublicKeyMatchesCert(rsaCng, cert, logger);
}
catch (CryptographicException ex)
{
logger?.Verbose(() =>
$"[PersistentCert] Cannot load private key for orphan check: {ex.Message}. Treating cert as unusable.");
return true;
}
}

/// <summary>
/// Returns <see langword="true"/> if the public key exported from <paramref name="containerKey"/>
/// matches the public key embedded in <paramref name="cert"/>.
/// A mismatch means the container holds different key material than when the cert was issued.
/// </summary>
/// <remarks>
/// Check 3 from the original proposal — comparing the CNG container's
/// <c>NCRYPT_LAST_MODIFIED_PROPERTY</c> against the cert's <c>NotBefore</c> — is
/// intentionally omitted. Both Check 3 and this modulus comparison detect the same event:
/// KeyGuard regenerating the key in the container post-reboot. This check is definitive:
/// two independently generated RSA keys sharing a modulus is computationally infeasible,
/// so a mismatch conclusively means the container was regenerated. Check 3 is a heuristic
/// with a known false-negative window (a reboot occurring within one minute of cert
/// issuance), and adds no coverage that this check does not already provide.
/// </remarks>
internal static bool PublicKeyMatchesCert(RSACng containerKey, X509Certificate2 cert, ILoggerAdapter logger)
{
try
{
var containerParams = containerKey.ExportParameters(includePrivateParameters: false);
using var certPubKey = cert.GetRSAPublicKey();
if (certPubKey is null)
return false;

var certParams = certPubKey.ExportParameters(includePrivateParameters: false);

return containerParams.Modulus is not null
&& certParams.Modulus is not null
&& containerParams.Modulus.AsSpan().SequenceEqual(certParams.Modulus)
&& containerParams.Exponent is not null
&& certParams.Exponent is not null
&& containerParams.Exponent.AsSpan().SequenceEqual(certParams.Exponent);
}
catch (CryptographicException ex)
{
logger?.Verbose(() =>
$"[PersistentCert] Public key export failed during orphan check: {ex.Message}. Treating cert as orphaned.");
return false;
}
}
}
}
Loading
Loading