Skip to content

Commit 6578b06

Browse files
MSI V2 client-side keys: add e2e + unit tests, fixes, hardware KSP updates
1 parent c813ed0 commit 6578b06

File tree

8 files changed

+157
-33
lines changed

8 files changed

+157
-33
lines changed

build/template-run-mi-e2e-azurearc.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ steps:
3737
codeCoverageEnabled: false
3838
failOnMinTestsNotRun: true
3939
minimumExpectedTests: '1'
40-
testFiltercriteria: 'TestCategory=MI_E2E_AzureArc'
40+
testFiltercriteria: '(TestCategory=MI_E2E_AzureArc|TestCategory=MI_E2E_KeyAcquisition_KeyGuard)'

build/template-run-mi-e2e-imds.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ steps:
3838
runInParallel: false
3939
failOnMinTestsNotRun: true
4040
minimumExpectedTests: '1'
41-
testFiltercriteria: 'TestCategory=MI_E2E_Imds'
41+
testFiltercriteria: '(TestCategory=MI_E2E_Imds|TestCategory=MI_E2E_KeyAcquisition_Hardware)'

src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ internal class ManagedIdentityAuthRequest : RequestBase
1919
private readonly AcquireTokenForManagedIdentityParameters _managedIdentityParameters;
2020
private static readonly SemaphoreSlim s_semaphoreSlim = new SemaphoreSlim(1, 1);
2121
private readonly ICryptographyManager _cryptoManager;
22+
private readonly IManagedIdentityKeyProvider _managedIdentityKeyProvider;
2223

2324
public ManagedIdentityAuthRequest(
2425
IServiceBundle serviceBundle,
@@ -28,13 +29,17 @@ public ManagedIdentityAuthRequest(
2829
{
2930
_managedIdentityParameters = managedIdentityParameters;
3031
_cryptoManager = serviceBundle.PlatformProxy.CryptographyManager;
32+
_managedIdentityKeyProvider = serviceBundle.PlatformProxy.ManagedIdentityKeyProvider;
3133
}
3234

3335
protected override async Task<AuthenticationResult> ExecuteAsync(CancellationToken cancellationToken)
3436
{
3537
AuthenticationResult authResult = null;
3638
ILoggerAdapter logger = AuthenticationRequestParameters.RequestContext.Logger;
3739

40+
ManagedIdentityKeyInfo keyInfo = await _managedIdentityKeyProvider.GetOrCreateKeyAsync(
41+
logger, cancellationToken).ConfigureAwait(false);
42+
3843
// 1. FIRST, handle ForceRefresh
3944
if (_managedIdentityParameters.ForceRefresh)
4045
{

src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/InMemoryManagedIdentityKeyProvider.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,11 @@ public async Task<ManagedIdentityKeyInfo> GetOrCreateKeyAsync(
8080
private static RSA CreateRsaKeyPair()
8181
{
8282
RSA rsa;
83-
#if NET462 || NET472
83+
#if NETFRAMEWORK
8484
// .NET Framework (Windows): use RSACng
8585
rsa = new RSACng();
8686
#else
87-
// Cross-platform (.NET Core/8+/Standard)
87+
// Crossplatform: RSA.Create() -> CNG (Windows) / OpenSSL (Linux).
8888
rsa = RSA.Create();
8989
#endif
9090
rsa.KeySize = 2048;

src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsCngKeyOperations.cs

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ namespace Microsoft.Identity.Client.ManagedIdentity.KeyProviders
1717
/// </summary>
1818
internal static class WindowsCngKeyOperations
1919
{
20-
private const string ProviderName = "Microsoft Software Key Storage Provider";
21-
private const string KeyName = "KeyGuardRSAKey";
20+
private const string SoftwareKspName = "Microsoft Software Key Storage Provider";
21+
private const string KeyGuardKeyName = "KeyGuardRSAKey";
22+
private const string HardwareKeyName = "HardwareRSAKey";
2223

2324
// --- KeyGuard path (RSA) ---
2425
public static bool TryGetOrCreateKeyGuard(ILoggerAdapter logger, out RSA rsa)
@@ -27,33 +28,51 @@ public static bool TryGetOrCreateKeyGuard(ILoggerAdapter logger, out RSA rsa)
2728

2829
try
2930
{
30-
// Try open by the known name first
31+
// Try open by the known name first (Software KSP, user scope, silent)
3132
CngKey key;
3233
try
3334
{
34-
key = CngKey.Open(KeyName, new CngProvider(ProviderName));
35+
key = CngKey.Open(
36+
KeyGuardKeyName,
37+
new CngProvider(SoftwareKspName),
38+
CngKeyOpenOptions.UserKey | CngKeyOpenOptions.Silent);
3539
}
3640
catch (CryptographicException)
3741
{
38-
// Not found -> create fresh
42+
// Not found -> create fresh (helper may return null if VBS unavailable)
3943
logger?.Verbose(() => "[MI][WinKeyProvider] KeyGuard key not found; creating fresh.");
4044
key = KeyGuardHelper.CreateFresh(logger);
4145
}
4246

47+
// If VBS is unavailable, CreateFresh() returns null. Bail out cleanly.
48+
if (key == null)
49+
{
50+
logger?.Verbose(() => "[MI][WinKeyProvider] KeyGuard unavailable (VBS off or not supported).");
51+
return false;
52+
}
53+
4354
// Ensure actually KeyGuard-protected; recreate if not
4455
if (!KeyGuardHelper.IsKeyGuardProtected(key))
4556
{
4657
logger?.Verbose(() => "[MI][WinKeyProvider] KeyGuard key found but not protected; recreating.");
4758
key.Dispose();
4859
key = KeyGuardHelper.CreateFresh(logger);
60+
61+
// Check again after recreate; still null or not protected -> give up KeyGuard path
62+
if (key == null || !KeyGuardHelper.IsKeyGuardProtected(key))
63+
{
64+
key?.Dispose();
65+
logger?.Verbose(() => "[MI][WinKeyProvider] Unable to obtain a KeyGuard-protected key.");
66+
return false;
67+
}
4968
}
5069

5170
rsa = new RSACng(key);
5271
if (rsa.KeySize < 2048)
5372
{
5473
try
5574
{ rsa.KeySize = 2048; }
56-
catch { }
75+
catch { /* some providers don't allow */ }
5776
}
5877
return true;
5978
}
@@ -76,39 +95,48 @@ public static bool TryGetOrCreateHardwareRsa(ILoggerAdapter logger, out RSA rsa)
7695

7796
try
7897
{
79-
CngProvider provider = new CngProvider(ProviderName);
80-
CngKeyOpenOptions openOpts = CngKeyOpenOptions.UserKey;
81-
82-
CngKey key = CngKey.Exists(KeyName, provider, openOpts)
83-
? CngKey.Open(KeyName, provider, openOpts)
84-
: CngKey.Create(
85-
CngAlgorithm.Rsa,
86-
KeyName,
87-
new CngKeyCreationParameters
88-
{
89-
Provider = provider,
90-
KeyUsage = CngKeyUsages.Signing,
91-
ExportPolicy = CngExportPolicies.None, // non-exportable
92-
KeyCreationOptions = CngKeyCreationOptions.MachineKey
93-
});
98+
// PCP (TPM) in USER scope
99+
CngProvider provider = new CngProvider(SoftwareKspName);
100+
CngKeyOpenOptions openOpts = CngKeyOpenOptions.UserKey | CngKeyOpenOptions.Silent;
101+
102+
CngKey key = CngKey.Exists(HardwareKeyName, provider, openOpts)
103+
? CngKey.Open(HardwareKeyName, provider, openOpts)
104+
: CreateUserPcpRsa(provider, HardwareKeyName);
94105

95106
rsa = new RSACng(key);
96107

97108
if (rsa.KeySize < 2048)
98109
{
99110
try
100111
{ rsa.KeySize = 2048; }
101-
catch { }
112+
catch { /* PCP typically ignores post-create change */ }
102113
}
103-
104-
logger?.Info("[MI][WinKeyProvider] Using Hardware key (RSA).");
114+
115+
logger?.Info("[MI][WinKeyProvider] Using Hardware key (RSA, PCP user).");
105116
return true;
106117
}
107-
catch (CryptographicException)
118+
catch (CryptographicException e)
108119
{
109-
logger?.Verbose(() => "[MI][WinKeyProvider] Exception creating Hardware key.");
120+
// Add HResult to make CI diagnostics actionable
121+
logger?.Verbose(() => "[MI][WinKeyProvider] Hardware key creation/open failed. " +
122+
$"HR=0x{e.HResult:X8}. {e.GetType().Name}: {e.Message}");
110123
return false;
111124
}
125+
126+
static CngKey CreateUserPcpRsa(CngProvider provider, string name)
127+
{
128+
var p = new CngKeyCreationParameters
129+
{
130+
Provider = provider,
131+
KeyUsage = CngKeyUsages.Signing,
132+
ExportPolicy = CngExportPolicies.None, // non-exportable (expected for TPM)
133+
KeyCreationOptions = CngKeyCreationOptions.None // USER scope
134+
};
135+
136+
p.Parameters.Add(new CngProperty("Length", BitConverter.GetBytes(2048), CngPropertyOptions.None));
137+
138+
return CngKey.Create(CngAlgorithm.Rsa, name, p);
139+
}
112140
}
113141
}
114142
}

src/client/Microsoft.Identity.Client/ManagedIdentity/KeyProviders/WindowsManagedIdentityKeyProvider.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,24 @@ public async Task<ManagedIdentityKeyInfo> GetOrCreateKeyAsync(
102102
$"[MI][WinKeyProvider] Exception creating Hardware key: {ex.GetType().Name}");
103103
}
104104

105+
// 3) In-memory fallback (software RSA)
106+
logger?.Info("[MI][WinKeyProvider] Falling back to in-memory RSA key (software).");
107+
if (ct.IsCancellationRequested)
108+
{
109+
logger?.Verbose(() => "[MI][WinKeyProvider] Cancellation requested before in-memory fallback.");
110+
ct.ThrowIfCancellationRequested();
111+
}
112+
113+
var fallback = new InMemoryManagedIdentityKeyProvider();
114+
_cached = await fallback.GetOrCreateKeyAsync(logger, ct).ConfigureAwait(false);
115+
116+
if (messageBuilder.Length > 0)
117+
{
118+
logger?.Verbose(() => "[MI][WinKeyProvider] Fallback reasons:\n" + messageBuilder.ToString().Trim());
119+
}
120+
105121
return _cached;
122+
106123
}
107124
finally
108125
{

src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityClient.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,11 @@ internal class ManagedIdentityClient
2020
private const string WindowsHimdsFilePath = "%Programfiles%\\AzureConnectedMachineAgent\\himds.exe";
2121
private const string LinuxHimdsFilePath = "/opt/azcmagent/bin/himds";
2222
private readonly AbstractManagedIdentity _identitySource;
23-
private readonly IManagedIdentityKeyProvider _provider;
2423

2524
public ManagedIdentityClient(RequestContext requestContext)
2625
{
2726
using (requestContext.Logger.LogMethodDuration())
2827
{
29-
30-
_provider = requestContext.ServiceBundle.PlatformProxy.ManagedIdentityKeyProvider;
3128
_identitySource = SelectManagedIdentitySource(requestContext);
3229
}
3330
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Security.Cryptography;
6+
using Microsoft.Identity.Client.ManagedIdentity.KeyGuard;
7+
using Microsoft.Identity.Client.ManagedIdentity.KeyProviders;
8+
using Microsoft.Identity.Test.Common.Core.Helpers;
9+
using Microsoft.VisualStudio.TestTools.UnitTesting;
10+
11+
namespace Microsoft.Identity.Test.E2E
12+
{
13+
[TestClass]
14+
public class ManagedIdentityKeyAcquisitionTests
15+
{
16+
private const string SoftwareKspName = "Microsoft Software Key Storage Provider";
17+
18+
// Runs on the AzureArc agent: must obtain a VBS/KeyGuard key.
19+
[TestMethod]
20+
[TestCategory("MI_E2E_KeyAcquisition_KeyGuard")]
21+
[RunOnAzureDevOps]
22+
public void KeyAcquisition_Fetches_KeyGuard_Key()
23+
{
24+
if (!OperatingSystem.IsWindows())
25+
{
26+
Assert.Inconclusive("This test runs on Windows agents only.");
27+
}
28+
29+
bool ok = WindowsCngKeyOperations.TryGetOrCreateKeyGuard(logger: null, out RSA rsa);
30+
Assert.IsTrue(ok, "Expected KeyGuard key on AzureArc agent.");
31+
32+
using (rsa)
33+
{
34+
var rsacng = rsa as RSACng;
35+
Assert.IsNotNull(rsacng, "Expected RSACng for KeyGuard.");
36+
Assert.IsTrue(
37+
KeyGuardHelper.IsKeyGuardProtected(rsacng.Key),
38+
"Expected KeyGuard (VBS) protected key on AzureArc agent.");
39+
}
40+
}
41+
42+
// Runs on the IMDS agent: must obtain a TPM/PCP hardware key (user scope).
43+
[TestMethod]
44+
[TestCategory("MI_E2E_KeyAcquisition_Hardware")]
45+
[RunOnAzureDevOps]
46+
public void KeyAcquisition_Fetches_Hardware_Key()
47+
{
48+
if (!OperatingSystem.IsWindows())
49+
{
50+
Assert.Inconclusive("This test runs on Windows agents only.");
51+
}
52+
53+
bool ok = WindowsCngKeyOperations.TryGetOrCreateHardwareRsa(logger: null, out RSA rsa);
54+
Assert.IsTrue(ok, "Expected TPM hardware key on IMDS agent.");
55+
56+
using (rsa)
57+
{
58+
var rsacng = rsa as RSACng;
59+
Assert.IsNotNull(rsacng, "Expected RSACng for hardware key.");
60+
61+
Assert.AreEqual(
62+
SoftwareKspName,
63+
rsacng.Key.Provider.Provider,
64+
"Expected TPM-backed key via Microsoft Software Key Storage Provider.");
65+
66+
// TPM keys created with ExportPolicy=None should not allow private export.
67+
bool privateExportable = true;
68+
try
69+
{ _ = rsacng.ExportParameters(true); }
70+
catch (CryptographicException) { privateExportable = false; }
71+
catch (NotSupportedException) { privateExportable = false; }
72+
73+
Assert.IsFalse(privateExportable, "Hardware (TPM) key should be non-exportable.");
74+
}
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)