diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs index ab19425b9e..aa23fd7fd3 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs @@ -17,6 +17,7 @@ using Microsoft.Identity.Client.Internal.Broker; using Microsoft.Identity.Client.Internal.ClientCredential; using Microsoft.Identity.Client.Kerberos; +using Microsoft.Identity.Client.ManagedIdentity.V2; using Microsoft.Identity.Client.PlatformsCommon.Interfaces; using Microsoft.Identity.Client.UI; using Microsoft.IdentityModel.Abstractions; @@ -126,6 +127,7 @@ public string ClientVersion public Func> AppTokenProvider; internal IRetryPolicyFactory RetryPolicyFactory { get; set; } + internal ICsrFactory CsrFactory { get; set; } #region ClientCredentials diff --git a/src/client/Microsoft.Identity.Client/AppConfig/BaseAbstractApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/BaseAbstractApplicationBuilder.cs index b60ae2dbe0..a1c0d6c5f1 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/BaseAbstractApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/BaseAbstractApplicationBuilder.cs @@ -15,6 +15,7 @@ using Microsoft.IdentityModel.Abstractions; using Microsoft.Identity.Client.Internal; using Microsoft.Identity.Client.Http.Retry; +using Microsoft.Identity.Client.ManagedIdentity.V2; #if SUPPORTS_SYSTEM_TEXT_JSON using System.Text.Json; @@ -39,6 +40,12 @@ internal BaseAbstractApplicationBuilder(ApplicationConfiguration configuration) { Config.RetryPolicyFactory = new RetryPolicyFactory(); } + + // Ensure the default csr factory is set if the test factory was not provided + if (Config.CsrFactory == null) + { + Config.CsrFactory = new DefaultCsrFactory(); + } } internal ApplicationConfiguration Config { get; } @@ -246,6 +253,17 @@ internal T WithRetryPolicyFactory(IRetryPolicyFactory factory) return (T)this; } + /// + /// Internal only: Allows tests to inject a custom csr factory. + /// + /// The csr factory to use. + /// The builder for chaining. + internal T WithCsrFactory(ICsrFactory factory) + { + Config.CsrFactory = factory; + return (T)this; + } + internal virtual ApplicationConfiguration BuildConfiguration() { ResolveAuthority(); diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs index 276fe67c78..67434999cb 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs @@ -82,7 +82,7 @@ public virtual async Task AuthenticateAsync( method: HttpMethod.Get, logger: _requestContext.Logger, doNotThrow: true, - mtlsCertificate: null, + mtlsCertificate: request.MtlsCertificate, validateServerCertificate: GetValidationCallback(), cancellationToken: cancellationToken, retryPolicy: retryPolicy).ConfigureAwait(false); @@ -97,7 +97,7 @@ public virtual async Task AuthenticateAsync( method: HttpMethod.Post, logger: _requestContext.Logger, doNotThrow: true, - mtlsCertificate: null, + mtlsCertificate: request.MtlsCertificate, validateServerCertificate: GetValidationCallback(), cancellationToken: cancellationToken, retryPolicy: retryPolicy) diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityRequest.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityRequest.cs index c5b9af2b73..6a7161d2c0 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityRequest.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityRequest.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using Microsoft.Identity.Client.ApiConfig.Parameters; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.OAuth2; @@ -26,7 +27,13 @@ internal class ManagedIdentityRequest public RequestType RequestType { get; set; } - public ManagedIdentityRequest(HttpMethod method, Uri endpoint, RequestType requestType = RequestType.ManagedIdentityDefault) + public X509Certificate2 MtlsCertificate { get; set; } + + public ManagedIdentityRequest( + HttpMethod method, + Uri endpoint, + RequestType requestType = RequestType.ManagedIdentityDefault, + X509Certificate2 mtlsCertificate = null) { Method = method; _baseEndpoint = endpoint; @@ -34,6 +41,7 @@ public ManagedIdentityRequest(HttpMethod method, Uri endpoint, RequestType reque BodyParameters = new Dictionary(); QueryParameters = new Dictionary(); RequestType = requestType; + MtlsCertificate = mtlsCertificate; } public Uri ComputeUri() diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs index d26ce6d819..f36b85033a 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/Csr.cs @@ -10,7 +10,7 @@ namespace Microsoft.Identity.Client.ManagedIdentity.V2 { internal class Csr { - internal static string Generate(string clientId, string tenantId, CuidInfo cuid) + internal static (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid) { using (RSA rsa = CreateRsaKeyPair()) { @@ -30,7 +30,7 @@ internal static string Generate(string clientId, string tenantId, CuidInfo cuid) "1.3.6.1.4.1.311.90.2.10", writer.Encode())); - return req.CreateSigningRequestPem(); + return (req.CreateSigningRequestPem(), rsa); } } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/DefaultCsrFactory.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/DefaultCsrFactory.cs new file mode 100644 index 0000000000..edbd183edb --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/DefaultCsrFactory.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; + +namespace Microsoft.Identity.Client.ManagedIdentity.V2 +{ + internal class DefaultCsrFactory : ICsrFactory + { + public (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuid) + { + return Csr.Generate(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 new file mode 100644 index 0000000000..84bae9409d --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ICsrFactory.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; + +namespace Microsoft.Identity.Client.ManagedIdentity.V2 +{ + internal interface ICsrFactory + { + (string csrPem, RSA privateKey) Generate(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 0ab5d73f98..d329bfbfa8 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs @@ -10,6 +10,7 @@ using Microsoft.Identity.Client.Http; using Microsoft.Identity.Client.Http.Retry; using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.PlatformsCommon.Shared; using Microsoft.Identity.Client.Utils; namespace Microsoft.Identity.Client.ManagedIdentity.V2 @@ -20,11 +21,16 @@ internal class ImdsV2ManagedIdentitySource : AbstractManagedIdentity public const string ImdsV2ApiVersion = "2.0"; public const string CsrMetadataPath = "/metadata/identity/getplatformmetadata"; public const string CertificateRequestPath = "/metadata/identity/issuecredential"; + public const string AcquireEntraTokenPath = "/oauth2/v2.0/token"; public static async Task GetCsrMetadataAsync( RequestContext requestContext, bool probeMode) { +#if NET462 + requestContext.Logger.Info(() => "[Managed Identity] IMDSv2 flow is not supported on .NET Framework 4.6.2. Cryptographic operations required for managed identity authentication are unavailable on this platform. Skipping IMDSv2 probe."); + return await Task.FromResult(null).ConfigureAwait(false); +#else var queryParams = ImdsV2QueryParamsHelper(requestContext); var headers = new Dictionary @@ -90,6 +96,7 @@ public static async Task GetCsrMetadataAsync( } return TryCreateCsrMetadata(response, requestContext.Logger, probeMode); +#endif } private static void ThrowProbeFailedException( @@ -251,11 +258,24 @@ private async Task ExecuteCertificateRequestAsync(st protected override async Task CreateRequestAsync(string resource) { var csrMetadata = await GetCsrMetadataAsync(_requestContext, false).ConfigureAwait(false); - var csr = Csr.Generate(csrMetadata.ClientId, csrMetadata.TenantId, csrMetadata.CuId); + var (csr, privateKey) = _requestContext.ServiceBundle.Config.CsrFactory.Generate(csrMetadata.ClientId, csrMetadata.TenantId, csrMetadata.CuId); var certificateRequestResponse = await ExecuteCertificateRequestAsync(csr).ConfigureAwait(false); - - throw new NotImplementedException(); + + // transform certificateRequestResponse.Certificate to x509 with private key + var mtlsCertificate = CommonCryptographyManager.AttachPrivateKeyToCert( + certificateRequestResponse.Certificate, + privateKey); + + ManagedIdentityRequest request = new(HttpMethod.Post, new Uri($"{certificateRequestResponse.MtlsAuthenticationEndpoint}/{certificateRequestResponse.TenantId}{AcquireEntraTokenPath}")); + request.Headers.Add("x-ms-client-request-id", _requestContext.CorrelationId.ToString()); + request.BodyParameters.Add("client_id", certificateRequestResponse.ClientId); + request.BodyParameters.Add("grant_type", certificateRequestResponse.Certificate); + request.BodyParameters.Add("scope", "https://management.azure.com/.default"); + request.RequestType = RequestType.Imds; + request.MtlsCertificate = mtlsCertificate; + + return request; } private static string ImdsV2QueryParamsHelper(RequestContext requestContext) diff --git a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/CommonCryptographyManager.cs b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/CommonCryptographyManager.cs index 20fc279fc4..187df64051 100644 --- a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/CommonCryptographyManager.cs +++ b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/CommonCryptographyManager.cs @@ -111,5 +111,114 @@ byte[] SignDataAndCacheProvider(string message) return signedData; } } + + /// + /// Attaches a private key to a certificate for use in mTLS authentication. + /// + /// The certificate in PEM format + /// The RSA private key to attach + /// An X509Certificate2 with the private key attached + /// Thrown when certificatePem or privateKey is null + /// Thrown when certificatePem is not a valid PEM certificate + /// Thrown when the certificate cannot be parsed + internal static X509Certificate2 AttachPrivateKeyToCert(string certificatePem, RSA privateKey) + { + if (string.IsNullOrEmpty(certificatePem)) + throw new ArgumentNullException(nameof(certificatePem)); + if (privateKey == null) + throw new ArgumentNullException(nameof(privateKey)); + + X509Certificate2 certificate; + +#if NET8_0_OR_GREATER + // .NET 8.0+ has direct PEM parsing support + certificate = X509Certificate2.CreateFromPem(certificatePem); + // Attach the private key and return a new certificate instance + return certificate.CopyWithPrivateKey(privateKey); +#else + // .NET Framework 4.7.2 and .NET Standard 2.0 - manual PEM parsing and private key attachment + certificate = ParseCertificateFromPem(certificatePem); + return AttachPrivateKeyToOlderFrameworks(certificate, privateKey); +#endif + } + +#if !NET8_0_OR_GREATER + /// + /// Parses a certificate from PEM format for older .NET versions. + /// + /// The certificate in PEM format + /// An X509Certificate2 instance + /// Thrown when the PEM format is invalid + /// Thrown when the Base64 content cannot be decoded + private static X509Certificate2 ParseCertificateFromPem(string certificatePem) + { + const string CertBeginMarker = "-----BEGIN CERTIFICATE-----"; + const string CertEndMarker = "-----END CERTIFICATE-----"; + + int startIndex = certificatePem.IndexOf(CertBeginMarker, StringComparison.Ordinal); + if (startIndex == -1) + { + throw new ArgumentException("Invalid PEM format: missing BEGIN CERTIFICATE marker", nameof(certificatePem)); + } + + startIndex += CertBeginMarker.Length; + int endIndex = certificatePem.IndexOf(CertEndMarker, startIndex, StringComparison.Ordinal); + if (endIndex == -1) + { + throw new ArgumentException("Invalid PEM format: missing END CERTIFICATE marker", nameof(certificatePem)); + } + + string base64Content = certificatePem.Substring(startIndex, endIndex - startIndex) + .Replace("\r", "") + .Replace("\n", "") + .Replace(" ", ""); + + if (string.IsNullOrEmpty(base64Content)) + { + throw new ArgumentException("Invalid PEM format: no certificate content found", nameof(certificatePem)); + } + + try + { + byte[] certBytes = Convert.FromBase64String(base64Content); + return new X509Certificate2(certBytes); + } + catch (FormatException ex) + { + throw new FormatException("Invalid PEM format: certificate content is not valid Base64", ex); + } + } + + /// + /// Attaches a private key to a certificate for older .NET Framework versions. + /// This method uses the older RSACng approach for .NET Framework 4.7.2 and .NET Standard 2.0. + /// + /// The certificate without private key + /// The RSA private key to attach + /// An X509Certificate2 with the private key attached + /// Thrown when private key attachment fails + private static X509Certificate2 AttachPrivateKeyToOlderFrameworks(X509Certificate2 certificate, RSA privateKey) + { + // For older frameworks, we need to use the legacy approach with RSACryptoServiceProvider + // First, export the RSA parameters from the provided private key + var parameters = privateKey.ExportParameters(includePrivateParameters: true); + + // Create a new RSACryptoServiceProvider with the correct key size + int keySize = parameters.Modulus.Length * 8; + using (var rsaProvider = new RSACryptoServiceProvider(keySize)) + { + // Import the parameters into the new provider + rsaProvider.ImportParameters(parameters); + + // Create a new certificate instance from the raw data + var certWithPrivateKey = new X509Certificate2(certificate.RawData); + + // Assign the private key using the legacy property + certWithPrivateKey.PrivateKey = rsaProvider; + + return certWithPrivateKey; + } + } +#endif } } diff --git a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs index 17a4ce5a72..04665fc0dd 100644 --- a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs +++ b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs @@ -8,12 +8,16 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using Castle.Core.Logging; using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Client.ManagedIdentity; using Microsoft.Identity.Client.ManagedIdentity.V2; using Microsoft.Identity.Client.OAuth2; using Microsoft.Identity.Client.Utils; using Microsoft.Identity.Test.Unit; +using Microsoft.VisualStudio.TestTools.UnitTesting.Logging; +using static Microsoft.Identity.Test.Common.Core.Helpers.ManagedIdentityTestUtil; namespace Microsoft.Identity.Test.Common.Core.Mocks { @@ -587,18 +591,25 @@ public static MsalTokenResponse CreateMsalRunTimeBrokerTokenResponse(string acce public static MockHttpMessageHandler MockCsrResponse( HttpStatusCode statusCode = HttpStatusCode.OK, - string responseServerHeader = "IMDS/150.870.65.1854") + string responseServerHeader = "IMDS/150.870.65.1854", + UserAssignedIdentityId idType = UserAssignedIdentityId.None, + string userAssignedId = null) { IDictionary expectedQueryParams = new Dictionary(); IDictionary expectedRequestHeaders = new Dictionary(); + if (idType != UserAssignedIdentityId.None && userAssignedId != null) + { + var userAssignedIdQueryParam = ImdsManagedIdentitySource.GetUserAssignedIdQueryParam((ManagedIdentityIdType)idType, userAssignedId, null); + expectedQueryParams.Add(userAssignedIdQueryParam.Value.Key, userAssignedIdQueryParam.Value.Value); + } expectedQueryParams.Add("cred-api-version", "2.0"); expectedRequestHeaders.Add("Metadata", "true"); string content = "{" + "\"cuId\": { \"vmId\": \"fake_vmId\" }," + - "\"clientId\": \"fake_client_id\"," + - "\"tenantId\": \"fake_tenant_id\"," + + "\"clientId\": \"" + TestConstants.ClientId + "\"," + + "\"tenantId\": \"" + TestConstants.TenantId + "\"," + "\"attestationEndpoint\": \"fake_attestation_endpoint\"" + "}"; @@ -626,5 +637,43 @@ public static MockHttpMessageHandler MockCsrResponseFailure() // 400 doesn't trigger the retry policy return MockCsrResponse(HttpStatusCode.BadRequest); } + + public static MockHttpMessageHandler MockCertificateRequestResponse( + UserAssignedIdentityId idType = UserAssignedIdentityId.None, + string userAssignedId = null) + { + IDictionary expectedQueryParams = new Dictionary(); + IDictionary expectedRequestHeaders = new Dictionary(); + if (idType != UserAssignedIdentityId.None && userAssignedId != null) + { + var userAssignedIdQueryParam = ImdsManagedIdentitySource.GetUserAssignedIdQueryParam((ManagedIdentityIdType)idType, userAssignedId, null); + expectedQueryParams.Add(userAssignedIdQueryParam.Value.Key, userAssignedIdQueryParam.Value.Value); + } + expectedQueryParams.Add("cred-api-version", ImdsV2ManagedIdentitySource.ImdsV2ApiVersion); + expectedRequestHeaders.Add("Metadata", "true"); + + string content = + "{" + + "\"client_id\": \"" + TestConstants.ClientId + "\"," + + "\"tenant_id\": \"" + TestConstants.TenantId + "\"," + + "\"certificate\": \"" + TestConstants.ValidPemCertificate + "\"," + + "\"identity_type\": \"fake_identity_type\"," + // "SystemAssigned" or "UserAssigned", it doesn't matter for these tests + "\"mtls_authentication_endpoint\": \"" + TestConstants.MtlsAuthenticationEndpoint + "\"," + + "}"; + + var handler = new MockHttpMessageHandler() + { + ExpectedUrl = $"{ImdsManagedIdentitySource.DefaultImdsBaseEndpoint}{ImdsV2ManagedIdentitySource.CertificateRequestPath}", + ExpectedMethod = HttpMethod.Post, + ExpectedQueryParams = expectedQueryParams, + ExpectedRequestHeaders = expectedRequestHeaders, + ResponseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content), + } + }; + + return handler; + } } } diff --git a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs index 7f8667d93f..017213d275 100644 --- a/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs +++ b/tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs @@ -460,6 +460,15 @@ private static MockHttpMessageHandler BuildMockHandlerForManagedIdentitySource( expectedQueryParams.Add("resource", resource); expectedRequestHeaders.Add("Metadata", "true"); break; + case ManagedIdentitySource.ImdsV2: + httpMessageHandler.ExpectedMethod = HttpMethod.Post; + expectedPostData = new Dictionary + { + { "client_id", TestConstants.ClientId }, + { "grant_type", TestConstants.ValidPemCertificate }, + { "scope", resource } + }; + break; case ManagedIdentitySource.CloudShell: httpMessageHandler.ExpectedMethod = HttpMethod.Post; expectedRequestHeaders.Add("Metadata", "true"); diff --git a/tests/Microsoft.Identity.Test.Common/TestConstants.cs b/tests/Microsoft.Identity.Test.Common/TestConstants.cs index 5a2ea2986a..35107976c7 100644 --- a/tests/Microsoft.Identity.Test.Common/TestConstants.cs +++ b/tests/Microsoft.Identity.Test.Common/TestConstants.cs @@ -156,6 +156,7 @@ public static HashSet s_scope public const string MiResourceId = "/subscriptions/ffa4aaa2-4444-4444-5555-e3ccedd3d046/resourcegroups/UAMI_group/providers/Microsoft.ManagedIdentityClient/userAssignedIdentities/UAMI"; public const string VmId = "test-vm-id"; public const string VmssId = "test-vmss-id"; + public const string MtlsAuthenticationEndpoint = "http://fake_mtls_authentication_endpoint"; public const string Claims = @"{""userinfo"":{""given_name"":{""essential"":true},""nickname"":null,""email"":{""essential"":true},""email_verified"":{""essential"":true},""picture"":null,""http://example.info/claims/groups"":null},""id_token"":{""auth_time"":{""essential"":true},""acr"":{""values"":[""urn:mace:incommon:iap:silver""]}}}"; public static readonly string[] ClientCapabilities = new[] { "cp1", "cp2" }; @@ -583,6 +584,46 @@ public static MsalTokenResponse CreateAadTestTokenResponseWithFoci() internal const string UserAccessToken = "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"; internal const string RefreshToken = "mhJDJ8wtjA3KxpRtuPAreZnMcJ2yKC2JUbpOGbRTdOCImLyQ2B4EIhv8AiA2cCEylZZfZsOsZrNsMBZZAAU9TQYYEO72QcdfnIWpAOeKkud5W2L8nMq6i9dx1EVIl09zFXhOJ79BdFbU0Eb5aUHlcqPCQjec62UKBLkZJmtMnoAa8cjvgIuxTdVM8FNdghe5nlCNTEVooKleTTEHNl2BrdyitLaWTKSP0lRqnFxriG0xWcJoSMsdS7Vt6HZd1TkwHIXycNMlCcCdUh5tOgqx1M8y8uoXK4OJ1LQmtkZvcQWcycvOCPACYakKM1pUQqwTxI6Y4HrL38sqQaSNxpF9OcFxOQWpuGodRekCbxXVbWclttIpvSOLaBhZ2ZBpcCBEeEMSmhqqYgajNwwwe9w88u0UsYKe6PBbaI48ENr02u2qBeLsIQ2HUyKlN3iVmX7u7MhgDWA3NNavMtlLmWd63NfuDgXpLI0O4cLhjAx8uoBIK8LntXPHPTxJ28o0yrszvD4gf7RdhuTq5VE15zne6iAJgIGfy7latGFzxuDMcML9OoXURHnNEHBgS9ZQCfNzYZ2O9flF1UjGpcBLEi7hHVHnrQb4y7c98dz9p62cvEMhorGx9kCwSIkOae5LheXPQkFIbsGyomNEwz3HZvR131VGAwdfmUUodvPr6LAAtmjl4sZ72PRqAo8EdQ0IFsWoypXVv51IooR87tO3uiG2DkxhIAwumOQdaJNxw1a0WS9mpQOmwFlvfbZkaIoUKgagHc8fVa1aHZntLGwH0S1iYixJiIrMnPYAeRdSp9mlHllrMX8xUIznobcZ5i8MpUYCKlUXMZ82S3XUJ5dJxARNRPxXlLJ5LPYBhUNkBLQen9Qmq3VZEV1RDJyhbGp6GAo14KsMtVAVYNmYPIgo85pCZgOwVEOBUycszu4AD3p4PT2ella4LVoqmTTMSA5GEWoeWb5JvEo222Z0oKr7UK8dGwpWRSbg8TNeODihJaTUDfErvbgaZnjIRpqfgtM5i1HfQbD7Yyft5PqyygUra7GYy7pjRrEvq95XQD8sAZ32ku9AqCo5qOB584iX881WErOoheQZokt1txqwuIMUyhVuMKNEXy70CeNTsb30ghQMZpZcXIkrLYyQCZ0gNmARhMKagCSdrpUtxudLk44yfmuwSQzBN3ifWfLZiFpU53qdPLZoTw5"; internal const string IdToken = "6GwdM7f6hHXfivavPozhaRqrbxvEysfXSMQyEKBwVgivPZTtmowsmYygchhIuxjeFFeq1ZPHjhxKFnulrvoY6TDerZY5xyOlg45bToI9Bu95qFvUrrt5r17UJcXdw4YkvEt10CcDDcLcEYw704RpVefvbpjbF24pOgIuafcAkDnbDA0Qea4ePuSC45Lw7zpJhbo9Gh8IfMX597fayBvMs3fh7frrm9KpWMCeKY3h99YSaCYjZFKp1ppvXXPE9bc4sh4pRDOfnv0Yr9J8u4elZevEE4qGddfgd3hYb18XPGRjPEMlWsh7tnwxwUm6OSZlMTHYuvwBENNMx7SUQmMeg4rCfgnbcNDkWpXCiSDVt1lLLv8F2GjYnM6De3v1Ks5lhBWx3grLggcN9LnXz92eJ1l5lTB2v0y9MgmFZ4gY43oIOW5n8G5HOx3bGOyjTw0TKKbyVa3mDj0A3QqW8eLTUJz42BNiGOf5m9prMSlpAW59CHCMJLatsj3IvGeCITsGAr3sUZEytORWUdxCfuIPwecQgU6bO7pNqNvZc1tJHHNwJlfS23ZkiFuEXqEThHYfxBCFxAzMDlzO0TOdWhvrb8hlNeAOcNhoAKxu7HXsePajKs4fU1rcdSxzNKwtASEla3p6jfJnnDtKf38RJZPaRRYMviqqWEMhjmqIvBm7sMaf8RyNNuYl7otZwmwNVCR1hzzmaTAy4kQce67FJqFba7uizrgwp9zsvK8muCHKKPvNthy7fHsxKmrBIm0bLcoePKK3wAID4kFvNQcxXp6rAOr8bLFF3bLEoYdzmF2QJz1frVZZHHPy90Cmlhw48EQN8NE2OllpdaykKt5k4rPcZQyitayNNhism30qh7eCBhcA7mm5Ja0S8X4VPlkwvgwg0mQuul6gakmja8xpnTrwiOdtao320GDmJaJA6zf3UTpNZTq9tdfBtUrjAD8RS0tNUBT3Ko8N2Lfh9ry8y9ESmRVIhch3rKY7UeefFAnkiwH2WwC57ZEsHtMP0SwKYtYKHZW9HkERCCyqOT1Mw0IavsLGFvchzMAvTnz4RwRBk6IrWgANvqT3F3Vexc2K0poKb71XZ4aMXxjqAzydGQAKpKJEJcqEvX9RD8nL76TF2LZIepiaZ3dbQImkqSjbF7aaY2JFoN9ZWlcSQKe8zdO8TIG16bF8W9R4ldDyzV39L33KcweG"; + + #region Test Certificate and Private Key (ValidPemCertificate & XmlPrivateKey) + /// + /// A test PEM-encoded X.509 certificate and its matching RSA private key. + /// These are used together in unit tests that require both a certificate and its private key. + /// The and are a matched pair: + /// - is a PEM-encoded certificate. + /// - is the corresponding RSA private key in XML format. + /// The certificate is valid for 100 years, ensuring it will not expire during the lifetime of the tests. + /// + internal const string ValidPemCertificate = @"-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIUSfjghyQB4FIS41rWfNcZHTLE/R4wDQYJKoZIhvcNAQEL +BQAwDzENMAsGA1UEAwwEVGVzdDAgFw0yNTA4MjgyMDIxMDBaGA8yMTI1MDgwNDIw +MjEwMFowDzENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALlc0S6TdwgQKGRl3Y/9uWNRpWo1WHiZtd1YdgCBt0rjxTqsbQUurU0B +9Kdk7QQ9srxmjimxGHaUFypbb39awqIdQQcuQvIUj5+sQh9zzCyR35bGQp8vwbna +5GlhAIbzsUi/y5kEGUMbuQN05XfoJSQrU35XZ8duQSDH5h9aDr6kuLcpDHo9/9vZ +iosPfqGPxZGtVjMvrJdVQGLJF35xD3LlX8xG2iJfVK/xYQVi3MgbRNQaL2lHtZaG +Ac1CToMUPO60xXrZkQE08hC907YTBcavUVQg4vrOaPpsCs+Fj6EJcasADAJeh1mG +Bn3kHFPCxBa2MKFraFPp53zOagTvYV0CAwEAAaNTMFEwHQYDVR0OBBYEFA9irQR/ +O6/V2JVyDEHFOdUDjAsyMB8GA1UdIwQYMBaAFA9irQR/O6/V2JVyDEHFOdUDjAsy +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAAOxtgYjtkUDVvWz +q/lkjLTdcLjPvmH0hF34A3uvX4zcjmqF845lfvszTuhc1mx5J6YLEzKfr4TrO3D3 +g2BnDLvhupok0wEmJ9yVwbt1laim7zP09gZqnUqYM9hYKDhwgLZAaG3zGNocxDEA +U7jazMGOGF7TweB7LdNuVI6CqgDOBQ8Cy2ObuZvzCI5Y7f+HucXpiJOu1xNa2ZZp +MpQycYEvi5TD+CL5CBv2fcKQRn/+u5B3ZXCD2C9jT/RZ7rH46mIG7nC7dS4J2o4J +jmlJIUAe2U6tRay5GvEmc/nZK8hd9y4BICzrykp9ENAoy9i+uaE1GGWeNgO+irrc +rAcLwto= +-----END CERTIFICATE-----"; + internal const string XmlPrivateKey = @" + uVzRLpN3CBAoZGXdj/25Y1GlajVYeJm13Vh2AIG3SuPFOqxtBS6tTQH0p2TtBD2yvGaOKbEYdpQXKltvf1rCoh1BBy5C8hSPn6xCH3PMLJHflsZCny/BudrkaWEAhvOxSL/LmQQZQxu5A3Tld+glJCtTfldnx25BIMfmH1oOvqS4tykMej3/29mKiw9+oY/Fka1WMy+sl1VAYskXfnEPcuVfzEbaIl9Ur/FhBWLcyBtE1BovaUe1loYBzUJOgxQ87rTFetmRATTyEL3TthMFxq9RVCDi+s5o+mwKz4WPoQlxqwAMAl6HWYYGfeQcU8LEFrYwoWtoU+nnfM5qBO9hXQ== + AQAB +

3pGBJXfhILNTsbRLHmUy7YVvD75HpvMCey2aaN4gU9Jvi1s2vQFU15a8p75Yt8UYHZDr+Yqwl1Jd4J+UtWsGqGBGNB1Ae4V1dwR8zUDKxXXee7G/dCDnIu4xpkZbPD+brcULcpF/Tdq/WsTbpCNhPgjHuo8hQY3vFv1NMla8mr0=

+ 1TSgE9DfTeqk0qybQM1r83M5ZwWKV0mPQBZl1VMs+VplB6E/6JAYWCKiq9ewgocOaktK94jtEtsaDhYeyojZFBlukt1lKp4kmkUwUSEmi3EFsprNakg+Bm6t85tEm5he5mG1ivHlE3M5lBWJ2A0r1g3jWSjYJlkk2nOwFE8bmyE= + UIcU0xmsusgnYAR7qWO0KXw90tRl2GHUY/z8ATVdPPbGpQU7qObya45+c7LLJrKJJyloN8GWYynKDZuvknRG1GUBAZoT2p1PAuD8xsbKlucuuFJ3kuzUtC66iA6ss//Ps++3VJyQEvsygQT480pZxLgoi7d9sNpJx2eeprf7RYE= + zwIZqyPSrUR2ZFdTJshNWEM4KN8oQzgY7pDQrx/jOviZv57A/n1qJaj7aP4zU4juZiZU06MPDI/P7H1tyBi3LNzEj7SG1apWv7MOBre5RQqoDZJggCFEl9o+65iGNMzs16NnMVFMqmXmMfH3tN6VAXDanWca96D2N2S8QfvNQgE= + Uoxh1dskd3C0N7SQ1nJXW7FyjB+J54R5yAcd8Zk0ukunhtuzsziQH4ZoMhBuzwxRwOaw0Umj77EcdEevuvFHn6LAK/solK2lkRcuKY2QTgkbYyYOxZNa1pJJaAfgzSGsBiwiGtHXl2eFLb2jfYDa4V/SV2B6BPOVheSUQGZlyYM= + Lkq21wnu7S2T2NbzyVUVKm+mfurJqHzCxX+lIKVEkEhn5ipPo76vew7k+bUj2C5MZ+64zEK1GFANpP9mzghtmSzzI4bzIx/tanQLo2047VyU2UO0Oaskl3TKHGMkTY+ok8GKaDF02aSfxPQ5poNsWycS1/eeLFklnLkviF7mVcfCoStSHAb+8dQzxO22Mu+oN2rXHinoNDSmFzUTx8cJapQhgji+GADRKF77Sfa5tHk/hCzVUXGBHgBs1jJM9cin2BBij8PngOaAAlby4gr07/r8SZU2uuXoxEDhpxf6mRTET5Wr2hxAyhu3bpZeCc0LokckNkzJPGUG6JaXXdUcgQ== +
"; + #endregion } internal static class Adfs2019LabConstants diff --git a/tests/Microsoft.Identity.Test.Unit/Helpers/TestCsrFactory.cs b/tests/Microsoft.Identity.Test.Unit/Helpers/TestCsrFactory.cs new file mode 100644 index 0000000000..6edd3936bb --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/Helpers/TestCsrFactory.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using Microsoft.Identity.Client.ManagedIdentity.V2; + +namespace Microsoft.Identity.Test.Unit.Helpers +{ + internal class TestCsrFactory : ICsrFactory + { + public (string csrPem, RSA privateKey) Generate(string clientId, string tenantId, CuidInfo cuId) + { + return ("mock-csr", CreateMockRsa()); + } + + /// + /// Creates a mock private key for testing purposes by loading key parameters from an XML string. + /// The XML format is used because it allows all necessary RSA parameters to be embedded directly in the code, + /// enabling deterministic and repeatable test runs. This method returns an object rather than a string, + /// as cryptographic operations in tests require a usable key instance, not just its serialized representation. + /// + public static RSA CreateMockRsa() + { + 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.FromXmlString(TestConstants.XmlPrivateKey); + return rsa; + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs index 2a67dd80d9..25322ec08b 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs @@ -3,14 +3,18 @@ using System; using System.Net; +using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.Identity.Client; using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Client.Internal; using Microsoft.Identity.Client.ManagedIdentity; using Microsoft.Identity.Client.ManagedIdentity.V2; +using Microsoft.Identity.Client.PlatformsCommon.Shared; using Microsoft.Identity.Test.Common.Core.Mocks; using Microsoft.Identity.Test.Unit.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Microsoft.Identity.Test.Common.Core.Helpers.ManagedIdentityTestUtil; namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests { @@ -18,6 +22,93 @@ namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests public class ImdsV2Tests : TestBase { private readonly TestRetryPolicyFactory _testRetryPolicyFactory = new TestRetryPolicyFactory(); + private readonly TestCsrFactory _testCsrFactory = new TestCsrFactory(); + + [TestMethod] + public async Task ImdsV2SAMIHappyPathAsync() + { + using (var httpManager = new MockHttpManager()) + { + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory) + .WithCsrFactory(_testCsrFactory); + + // Disabling shared cache options to avoid cross test pollution. + miBuilder.Config.AccessorOptions = null; + + var mi = miBuilder.Build(); + + httpManager.AddMockHandler(MockHelpers.MockCsrResponse()); // initial probe + httpManager.AddMockHandler(MockHelpers.MockCsrResponse()); // do it again, since CsrMetadata from initial probe is not cached + httpManager.AddMockHandler(MockHelpers.MockCertificateRequestResponse()); + httpManager.AddManagedIdentityMockHandler( + $"{TestConstants.MtlsAuthenticationEndpoint}/{TestConstants.TenantId}{ImdsV2ManagedIdentitySource.AcquireEntraTokenPath}", + ManagedIdentityTests.Resource, + MockHelpers.GetMsiSuccessfulResponse(), + ManagedIdentitySource.ImdsV2); + + var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .ExecuteAsync().ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .ExecuteAsync().ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource); + } + } + + [DataTestMethod] + [DataRow(UserAssignedIdentityId.ClientId, TestConstants.ClientId)] + [DataRow(UserAssignedIdentityId.ResourceId, TestConstants.MiResourceId)] + [DataRow(UserAssignedIdentityId.ObjectId, TestConstants.ObjectId)] + public async Task ImdsV2UAMIHappyPathAsync( + UserAssignedIdentityId userAssignedIdentityId, + string userAssignedId) + { + using (var httpManager = new MockHttpManager()) + { + var miBuilder = CreateMIABuilder(userAssignedId, userAssignedIdentityId); + miBuilder + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory) + .WithCsrFactory(_testCsrFactory); + + // Disabling shared cache options to avoid cross test pollution. + miBuilder.Config.AccessorOptions = null; + + var mi = miBuilder.Build(); + + httpManager.AddMockHandler(MockHelpers.MockCsrResponse(idType: userAssignedIdentityId, userAssignedId: userAssignedId)); // initial probe + httpManager.AddMockHandler(MockHelpers.MockCsrResponse(idType: userAssignedIdentityId, userAssignedId: userAssignedId)); // do it again, since CsrMetadata from initial probe is not cached + httpManager.AddMockHandler(MockHelpers.MockCertificateRequestResponse(userAssignedIdentityId, userAssignedId)); + httpManager.AddManagedIdentityMockHandler( + $"{TestConstants.MtlsAuthenticationEndpoint}/{TestConstants.TenantId}{ImdsV2ManagedIdentitySource.AcquireEntraTokenPath}", + ManagedIdentityTests.Resource, + MockHelpers.GetMsiSuccessfulResponse(), + ManagedIdentitySource.ImdsV2); + + var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .ExecuteAsync().ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .ExecuteAsync().ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource); + } + } [TestMethod] public async Task GetCsrMetadataAsyncSucceeds() @@ -141,8 +232,8 @@ public void TestCsrGeneration_OnlyVmId() VmId = TestConstants.VmId }; - var csrPem = Csr.Generate(TestConstants.ClientId, TestConstants.TenantId, cuid); - CsrValidator.ValidateCsrContent(csrPem, TestConstants.ClientId, TestConstants.TenantId, cuid); + var (csr, _) = Csr.Generate(TestConstants.ClientId, TestConstants.TenantId, cuid); + CsrValidator.ValidateCsrContent(csr, TestConstants.ClientId, TestConstants.TenantId, cuid); } [TestMethod] @@ -154,8 +245,8 @@ public void TestCsrGeneration_VmIdAndVmssId() VmssId = TestConstants.VmssId }; - var csrPem = Csr.Generate(TestConstants.ClientId, TestConstants.TenantId, cuid); - CsrValidator.ValidateCsrContent(csrPem, TestConstants.ClientId, TestConstants.TenantId, cuid); + var (csr, _) = Csr.Generate(TestConstants.ClientId, TestConstants.TenantId, cuid); + CsrValidator.ValidateCsrContent(csr, TestConstants.ClientId, TestConstants.TenantId, cuid); } [TestMethod] @@ -175,5 +266,175 @@ public void TestCsrGeneration_MalformedPem_ArgumentException(string malformedPem Assert.ThrowsException(() => CsrValidator.ParseCsrFromPem(malformedPem)); } + + #region AttachPrivateKeyToCert Tests + + [TestMethod] + public void AttachPrivateKeyToCert_ValidInputs_ReturnsValidCertificate() + { + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + using (RSA rsa = RSA.Create()) + { + // For this test, we just want to verify that the method doesn't crash + // The actual certificate/private key matching isn't critical for the unit test + var exception = Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert(TestConstants.ValidPemCertificate, rsa)); + + // The test should fail with a CryptographicUnexpectedOperationException because the RSA key doesn't match + // the certificate, but this validates that the method is working correctly + Assert.IsNotNull(exception.Message); + } + } + + [TestMethod] + public void AttachPrivateKeyToCert_NullCertificatePem_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + using (RSA rsa = RSA.Create()) + { + Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert(null, rsa)); + } + } + + [TestMethod] + public void AttachPrivateKeyToCert_EmptyCertificatePem_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + using (RSA rsa = RSA.Create()) + { + Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert("", rsa)); + } + } + + [TestMethod] + public void AttachPrivateKeyToCert_NullPrivateKey_ThrowsArgumentNullException() + { + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert(TestConstants.ValidPemCertificate, null)); + } + + [TestMethod] + public void AttachPrivateKeyToCert_InvalidPemFormat_ThrowsArgumentException() + { + const string InvalidPemNoCertMarker = @"This is not a valid PEM certificate"; + + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + using (RSA rsa = RSA.Create()) + { + Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert(InvalidPemNoCertMarker, rsa)); + } + } + + [TestMethod] + public void AttachPrivateKeyToCert_MissingBeginMarker_ThrowsArgumentException() + { + const string InvalidPemMissingBeginMarker = @"MIICXTCCAUWgAwIBAgIJAKPiQh26MIuPMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +-----END CERTIFICATE-----"; + + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + using (RSA rsa = RSA.Create()) + { + Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert(InvalidPemMissingBeginMarker, rsa)); + } + } + + [TestMethod] + public void AttachPrivateKeyToCert_MissingEndMarker_ThrowsArgumentException() + { + const string InvalidPemMissingEndMarker = @"-----BEGIN CERTIFICATE----- +MIICXTCCAUWgAwIBAgIJAKPiQh26MIuPMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV"; + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + using (RSA rsa = RSA.Create()) + { + Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert(InvalidPemMissingEndMarker, rsa)); + } + } + + [TestMethod] + public void AttachPrivateKeyToCert_BadBase64Content_ThrowsFormatException() + { + const string InvalidPemBadBase64 = @"-----BEGIN CERTIFICATE----- +Invalid@#$%Base64Content! +-----END CERTIFICATE-----"; + + using var httpManager = new MockHttpManager(); + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithHttpManager(httpManager) + .WithRetryPolicyFactory(_testRetryPolicyFactory); + var managedIdentityApp = miBuilder.BuildConcrete(); + + var requestContext = new RequestContext(managedIdentityApp.ServiceBundle, Guid.NewGuid(), null); + var imdsV2Source = new ImdsV2ManagedIdentitySource(requestContext); + + using (RSA rsa = RSA.Create()) + { + Assert.ThrowsException(() => + CommonCryptographyManager.AttachPrivateKeyToCert(InvalidPemBadBase64, rsa)); + } + } + + #endregion } }