Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,171 @@ 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;
}
}
Comment on lines +439 to +457

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}'.");
}
Comment on lines +474 to +487
}
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
Loading
Loading