From 0128add33d94ac4d25089a79201296f798f63f3e Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Thu, 4 Sep 2025 06:23:54 -0700 Subject: [PATCH] Add new MTLS PoP package for MSI flows with new MTLS API --- LibsAndSamples.sln | 91 +++++++++- build/template-pack-and-sign-all-nugets.yaml | 7 + .../Attestation/AttestationClient.cs | 118 ++++++++++++ .../Attestation/AttestationClientLib.cs | 45 +++++ .../Attestation/AttestationErrors.cs | 27 +++ .../Attestation/AttestationLogger.cs | 51 ++++++ .../Attestation/AttestationResult.cs | 18 ++ .../Attestation/AttestationResultErrorCode.cs | 125 +++++++++++++ .../Attestation/AttestationStatus.cs | 30 ++++ .../Attestation/NativeDiagnostics.cs | 46 +++++ .../Attestation/NativeDllResolver.cs | 94 ++++++++++ .../Attestation/WindowsDllLoader.cs | 59 ++++++ .../IsExternalInit.cs | 11 ++ .../ManagedIdentityPopExtensions.cs | 25 +++ .../Microsoft.Identity.Client.MtlsPop.csproj | 64 +++++++ .../PopKeyAttestor.cs | 58 ++++++ .../PublicApi/PublicAPI.Shipped.txt | 1 + .../PublicApi/PublicAPI.Unshipped.txt | 24 +++ .../AcquireTokenCommonParameters.cs | 3 +- .../AuthenticationRequestParameters.cs | 2 + .../Requests/ManagedIdentityAuthRequest.cs | 10 +- .../Microsoft.Identity.Client.csproj | 5 + .../Properties/InternalsVisibleTo.cs | 1 + .../KeyGuardAttestationTests.cs | 169 ++++++++++++++++++ .../Microsoft.Identity.Test.E2E.MSI.csproj | 1 + .../KeyGuardAttestation.csproj | 14 ++ .../KeyGuardAttestation/Program.cs | 71 ++++++++ .../ManagedIdentityAppVM.csproj | 1 + .../ManagedIdentityAppVM/Program.cs | 2 + 29 files changed, 1166 insertions(+), 7 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClient.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClientLib.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationErrors.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationLogger.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResult.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResultErrorCode.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationStatus.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDiagnostics.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDllResolver.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Attestation/WindowsDllLoader.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/IsExternalInit.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/ManagedIdentityPopExtensions.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/Microsoft.Identity.Client.MtlsPop.csproj create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/PopKeyAttestor.cs create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Shipped.txt create mode 100644 src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Unshipped.txt create mode 100644 tests/Microsoft.Identity.Test.E2e/KeyGuardAttestationTests.cs create mode 100644 tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/KeyGuardAttestation.csproj create mode 100644 tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/Program.cs diff --git a/LibsAndSamples.sln b/LibsAndSamples.sln index 9fa92d549d..a353f606eb 100644 --- a/LibsAndSamples.sln +++ b/LibsAndSamples.sln @@ -46,7 +46,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "all", "all", "{C44AADC0-1E9 .editorconfig = .editorconfig .gitattributes = .gitattributes .gitignore = .gitignore - CHANGELOG.md = CHANGELOG.md build\CodeCoverage.runsettings = build\CodeCoverage.runsettings build\credscan-exclusion.json = build\credscan-exclusion.json Directory.Build.props = Directory.Build.props @@ -196,6 +195,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MacMauiAppWithBroker", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MacConsoleAppWithBroker", "tests\devapps\MacConsoleAppWithBroker\MacConsoleAppWithBroker.csproj", "{DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Client.MtlsPop", "src\client\Microsoft.Identity.Client.MtlsPop\Microsoft.Identity.Client.MtlsPop.csproj", "{269A7A67-E48E-49A6-936B-60F1BE51F0CD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyGuardAttestation", "tests\devapps\Managed Identity apps\KeyGuardAttestationApp\KeyGuardAttestation\KeyGuardAttestation.csproj", "{701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug + MobileApps|Any CPU = Debug + MobileApps|Any CPU @@ -2022,6 +2025,90 @@ Global {DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}.Release|x64.Build.0 = Release|Any CPU {DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}.Release|x86.ActiveCfg = Release|Any CPU {DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}.Release|x86.Build.0 = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|Any CPU.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|Any CPU.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|ARM.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|ARM.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|ARM64.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|ARM64.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|iPhone.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|iPhone.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|iPhoneSimulator.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|x64.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|x64.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|x86.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug + MobileApps|x86.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|ARM.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|ARM.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|ARM64.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|iPhone.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|x64.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Debug|x86.Build.0 = Debug|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|Any CPU.Build.0 = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|ARM.ActiveCfg = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|ARM.Build.0 = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|ARM64.ActiveCfg = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|ARM64.Build.0 = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|iPhone.ActiveCfg = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|iPhone.Build.0 = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|x64.ActiveCfg = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|x64.Build.0 = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|x86.ActiveCfg = Release|Any CPU + {269A7A67-E48E-49A6-936B-60F1BE51F0CD}.Release|x86.Build.0 = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|Any CPU.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|Any CPU.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|ARM.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|ARM.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|ARM64.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|ARM64.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|iPhone.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|iPhone.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|iPhoneSimulator.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|x64.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|x64.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|x86.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug + MobileApps|x86.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|Any CPU.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|ARM.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|ARM.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|ARM64.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|iPhone.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|x64.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|x64.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|x86.ActiveCfg = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Debug|x86.Build.0 = Debug|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|Any CPU.ActiveCfg = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|Any CPU.Build.0 = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|ARM.ActiveCfg = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|ARM.Build.0 = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|ARM64.ActiveCfg = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|ARM64.Build.0 = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|iPhone.ActiveCfg = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|iPhone.Build.0 = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|x64.ActiveCfg = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|x64.Build.0 = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|x86.ActiveCfg = Release|Any CPU + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -2081,6 +2168,8 @@ Global {97995B86-AA0F-3AF9-DA40-85A6263E4391} = {9B0B5396-4D95-4C15-82ED-DC22B5A3123F} {AEF6BB00-931F-4638-955D-24D735625C34} = {34BE693E-3496-45A4-B1D2-D3A0E068EEDB} {DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0} = {34BE693E-3496-45A4-B1D2-D3A0E068EEDB} + {269A7A67-E48E-49A6-936B-60F1BE51F0CD} = {1A37FD75-94E9-4D6F-953A-0DABBD7B49E9} + {701B8CD0-6D28-4B06-B8B4-9C47AF6E0793} = {BCAEE9AE-8D3E-4C77-A2E4-134E1552D5F8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {020399A9-DC27-4B82-9CAA-EF488665AC27} diff --git a/build/template-pack-and-sign-all-nugets.yaml b/build/template-pack-and-sign-all-nugets.yaml index b5d5377a21..e3a722d77d 100644 --- a/build/template-pack-and-sign-all-nugets.yaml +++ b/build/template-pack-and-sign-all-nugets.yaml @@ -37,6 +37,13 @@ steps: ProjectRootPath: '$(Build.SourcesDirectory)\$(MsalSourceDir)src\client' AssemblyName: 'Microsoft.Identity.Client.Extensions.Msal' +# Sign binary and pack Microsoft.Identity.Client.MtlsPop +- template: template-pack-and-sign-nuget.yaml + parameters: + BuildConfiguration: ${{ parameters.BuildConfiguration }} + ProjectRootPath: '$(Build.SourcesDirectory)\$(MsalSourceDir)src\client' + AssemblyName: 'Microsoft.Identity.Client.MtlsPop' + # Copy all packages out to staging - task: CopyFiles@2 displayName: 'Copy Files to: $(Build.ArtifactStagingDirectory)\packages' diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClient.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClient.cs new file mode 100644 index 0000000000..1dc4b88419 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClient.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + /// + /// Managed façade for AttestationClientLib.dll. Holds initialization state, + /// does ref-count hygiene on , and returns a JWT. + /// + public sealed class AttestationClient : IDisposable + { + private bool _initialized; + + /// + /// AttestationClient constructor. Pro-actively verifies the native DLL. + /// + /// + public AttestationClient() + { + /* step 0 ── ensure the resolver probes all valid locations + (env override → app base → System32/SysWOW64 → PATH) */ + NativeDllResolver.EnsureLoaded(); + + /* step 1 ── optional proactive verification (non-fatal) + Keep the probe for diagnostics, but do NOT throw here; if the DLL + is truly unavailable/mismatched, InitAttestationLib will fail. */ + string dllError = NativeDiagnostics.ProbeNativeDll(); + // intentionally not throwing on dllError to avoid path-specific false negatives + + /* step 2 ── load & initialize (logger is required by native lib) */ + var info = new AttestationClientLib.AttestationLogInfo + { + Log = AttestationLogger.ConsoleLogger, // minimal rooted delegate; works on netstandard2.0 & net8.0 + Ctx = IntPtr.Zero + }; + + _initialized = AttestationClientLib.InitAttestationLib(ref info) == 0; + if (!_initialized) + throw new InvalidOperationException("Failed to initialize AttestationClientLib."); + } + + /// + /// Calls the native AttestKeyGuardImportKey and returns a structured result. + /// + public AttestationResult Attest(string endpoint, + SafeNCryptKeyHandle keyHandle, + string clientId) + { + if (!_initialized) + return new(AttestationStatus.NotInitialized, null, -1, + "Native library not initialized."); + + IntPtr buf = IntPtr.Zero; + bool addRef = false; + + try + { + keyHandle.DangerousAddRef(ref addRef); + + int rc = AttestationClientLib.AttestKeyGuardImportKey( + endpoint, null, null, keyHandle, out buf, clientId); + + if (rc != 0) + return new(AttestationStatus.NativeError, null, rc, null); + + if (buf == IntPtr.Zero) + return new(AttestationStatus.TokenEmpty, null, 0, + "rc==0 but token buffer was null."); + + string jwt = Marshal.PtrToStringAnsi(buf)!; + return new(AttestationStatus.Success, jwt, 0, null); + } + catch (DllNotFoundException ex) + { + return new(AttestationStatus.Exception, null, -1, + $"Native DLL not found: {ex.Message}"); + } + catch (BadImageFormatException ex) + { + return new(AttestationStatus.Exception, null, -1, + $"Architecture mismatch (x86/x64) or corrupted DLL: {ex.Message}"); + } + catch (SEHException ex) + { + return new(AttestationStatus.Exception, null, -1, + $"Native library raised SEHException: {ex.Message}"); + } + catch (Exception ex) + { + return new(AttestationStatus.Exception, null, -1, ex.Message); + } + finally + { + if (buf != IntPtr.Zero) + AttestationClientLib.FreeAttestationToken(buf); + if (addRef) + keyHandle.DangerousRelease(); + } + } + + /// + /// Disposes the client, releasing any resources and un-initializing the native library. + /// + public void Dispose() + { + if (_initialized) + { + AttestationClientLib.UninitAttestationLib(); + _initialized = false; + } + GC.SuppressFinalize(this); + } + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClientLib.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClientLib.cs new file mode 100644 index 0000000000..df84387024 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationClientLib.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Win32.SafeHandles; +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + internal static class AttestationClientLib + { + internal enum LogLevel { Error, Warn, Info, Debug } + + internal delegate void LogFunc( + IntPtr ctx, string tag, LogLevel lvl, string func, int line, string msg); + + [StructLayout(LayoutKind.Sequential)] + internal struct AttestationLogInfo + { + public LogFunc Log; + public IntPtr Ctx; + } + + [DllImport("AttestationClientLib.dll", CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Ansi)] + internal static extern int InitAttestationLib(ref AttestationLogInfo info); + + [DllImport("AttestationClientLib.dll", CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Ansi)] + internal static extern int AttestKeyGuardImportKey( + string endpoint, + string authToken, + string clientPayload, + SafeNCryptKeyHandle keyHandle, + out IntPtr token, + string clientId); + + [DllImport("AttestationClientLib.dll", CallingConvention = CallingConvention.Cdecl)] + internal static extern void FreeAttestationToken(IntPtr token); + + [DllImport("AttestationClientLib.dll", CallingConvention = CallingConvention.Cdecl)] + internal static extern void UninitAttestationLib(); + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationErrors.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationErrors.cs new file mode 100644 index 0000000000..0c47ceed76 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationErrors.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + internal static class AttestationErrors + { + internal static string Describe(AttestationResultErrorCode rc) => rc switch + { + AttestationResultErrorCode.ERRORCURLINITIALIZATION + => "libcurl failed to initialize (DLL missing or version mismatch).", + AttestationResultErrorCode.ERRORHTTPREQUESTFAILED + => "Could not reach the attestation service (network / proxy?).", + AttestationResultErrorCode.ERRORATTESTATIONFAILED + => "The enclave rejected the evidence (key type / PCR policy).", + AttestationResultErrorCode.ERRORJWTDECRYPTIONFAILED + => "The JWT returned by the service could not be decrypted.", + AttestationResultErrorCode.ERRORLOGGERINITIALIZATION + => "Native logger setup failed (rare).", + _ => rc.ToString() // default: enum name + }; + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationLogger.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationLogger.cs new file mode 100644 index 0000000000..574a1d1821 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationLogger.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + internal static class AttestationLogger + { + /// + /// Attestation Logger + /// + internal static readonly AttestationClientLib.LogFunc ConsoleLogger = (ctx, tag, lvl, func, line, msg) => + { + try + { + string sTag = ToText(tag); + string sFunc = ToText(func); + string sMsg = ToText(msg); + + var lineText = $"[MtlsPop][{lvl}] {sTag} {sFunc}:{line} {sMsg}"; + + // Default: Trace (respects listeners; safe for all app types) + Trace.WriteLine(lineText); + + // Opt-in console mirroring for local debugging + if (Environment.GetEnvironmentVariable("MSAL_MTLSPOP_LOG_TO_CONSOLE") == "1") + { + Console.WriteLine(lineText); + } + } + catch + { + } + }; + + // Converts either string or IntPtr (char*) to text. Works with any LogFunc variant. + private static string ToText(object value) + { + if (value is IntPtr p && p != IntPtr.Zero) + { + try + { return Marshal.PtrToStringAnsi(p) ?? string.Empty; } + catch { return string.Empty; } + } + return value?.ToString() ?? string.Empty; + } + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResult.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResult.cs new file mode 100644 index 0000000000..61e78efc0c --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResult.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + /// + /// AttestationResult is the result of an attestation operation. + /// + /// + /// + /// + /// + public sealed record AttestationResult( + AttestationStatus Status, + string Jwt, + int NativeErrorCode, + string ErrorMessage); +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResultErrorCode.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResultErrorCode.cs new file mode 100644 index 0000000000..4f02375292 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationResultErrorCode.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + /// + /// Error codes returned by AttestationClientLib.dll. + /// A value of (0) indicates success; all other + /// values are negative and represent specific failure categories. + /// + internal enum AttestationResultErrorCode + { + /// The operation completed successfully. + SUCCESS = 0, + + /// libcurl could not be initialized inside the native library. + ERRORCURLINITIALIZATION = -1, + + /// The HTTP response body could not be parsed (malformed JSON, invalid JWT, etc.). + ERRORRESPONSEPARSING = -2, + + /// Managed-Identity (MSI) access token could not be obtained. + ERRORMSITOKENNOTFOUND = -3, + + /// The HTTP request exceeded the maximum retry count configured by the native client. + ERRORHTTPREQUESTEXCEEDEDRETRIES = -4, + + /// An HTTP request to the attestation service failed (network error, non-200 status, timeout, etc.). + ERRORHTTPREQUESTFAILED = -5, + + /// The attestation enclave rejected the supplied evidence (policy or signature failure). + ERRORATTESTATIONFAILED = -6, + + /// libcurl reported “couldn’t send” (DNS resolution, TLS handshake, or socket error). + ERRORSENDINGCURLREQUESTFAILED = -7, + + /// One or more input parameters passed to the native API were invalid or null. + ERRORINVALIDINPUTPARAMETER = -8, + + /// Validation of the attestation parameters failed on the client side. + ERRORATTESTATIONPARAMETERSVALIDATIONFAILED = -9, + + /// Native client failed to allocate heap memory. + ERRORFAILEDMEMORYALLOCATION = -10, + + /// Could not retrieve OS build / version information required for the attestation payload. + ERRORFAILEDTOGETOSINFO = -11, + + /// Internal TPM failure while gathering quotes or PCR values. + ERRORTPMINTERNALFAILURE = -12, + + /// TPM operation (e.g., signing the quote) failed. + ERRORTPMOPERATIONFAILURE = -13, + + /// The returned JWT could not be decrypted on the client. + ERRORJWTDECRYPTIONFAILED = -14, + + /// JWT decryption failed due to a TPM error. + ERRORJWTDECRYPTIONTPMERROR = -15, + + /// JSON in the service response was invalid or lacked required fields. + ERRORINVALIDJSONRESPONSE = -16, + + /// The VCEK certificate blob returned from the service was empty. + ERROREMPTYVCEKCERT = -17, + + /// The service response body was empty. + ERROREMPTYRESPONSE = -18, + + /// The HTTP request body generated by the client was empty. + ERROREMPTYREQUESTBODY = -19, + + /// Failed to parse the host-configuration-level (HCL) report. + ERRORHCLREPORTPARSINGFAILURE = -20, + + /// The retrieved HCL report was empty. + ERRORHCLREPORTEMPTY = -21, + + /// Could not extract JWK information from the attestation evidence. + ERROREXTRACTINGJWKINFO = -22, + + /// Failed converting a JWK structure to an RSA public key. + ERRORCONVERTINGJWKTORSAPUB = -23, + + /// EVP initialization for RSA encryption failed (OpenSSL). + ERROREVPPKEYENCRYPTINITFAILED = -24, + + /// EVP encryption failed when building the attestation claim. + ERROREVPPKEYENCRYPTFAILED = -25, + + /// Failed to decrypt data due to a TPM error. + ERRORDATADECRYPTIONTPMERROR = -26, + + /// Parsing DNS information for the attestation service endpoint failed. + ERRORPARSINGDNSINFO = -27, + + /// Failed to parse the attestation response envelope. + ERRORPARSINGATTESTATIONRESPONSE = -28, + + /// Provisioning of the Attestation Key (AK) certificate failed. + ERRORAKCERTPROVISIONINGFAILED = -29, + + /// Initialising the native attestation client failed. + ERRORCLIENTINITFAILED = -30, + + /// The service returned an empty JWT. + ERROREMPTYJWTRESPONSE = -31, + + /// Creating the KeyGuard attestation report failed on the client. + ERRORCREATEKGATTESTATIONREPORT = -32, + + /// Failed to extract the public key from the import-only key. + ERROREXTRACTIMPORTKEYPUB = -33, + + /// An unexpected C++ exception occurred inside the native client. + ERRORUNEXPECTEDEXCEPTION = -34, + + /// Initialising the native logger failed (file I/O / permissions / path issues). + ERRORLOGGERINITIALIZATION = -35 + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationStatus.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationStatus.cs new file mode 100644 index 0000000000..f8efeea78f --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/AttestationStatus.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + /// + /// High-level outcome categories returned by . + /// + public enum AttestationStatus + { + /// Everything succeeded; is populated. + Success = 0, + + /// Native library returned a non-zero AttestationResultErrorCode. + NativeError = 1, + + /// rc == 0 but the token buffer was null/empty. + TokenEmpty = 2, + + /// could not initialize the native DLL. + NotInitialized = 3, + + /// Any managed exception thrown while attempting the call. + Exception = 4 + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDiagnostics.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDiagnostics.cs new file mode 100644 index 0000000000..9482039c8e --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDiagnostics.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ComponentModel; +using System.IO; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + internal static class NativeDiagnostics + { + private const string NativeDll = "AttestationClientLib.dll"; + + internal static string ProbeNativeDll() + { + string path = Path.Combine(AppContext.BaseDirectory, NativeDll); + + if (!File.Exists(path)) + return $"Native DLL not found at: {path}"; + + IntPtr h; + + try + { + h = WindowsDllLoader.Load(path); + } + catch (Win32Exception w32) + { + return w32.NativeErrorCode switch + { + 193 or 216 => $"{NativeDll} is the wrong architecture for this process.", + 126 => $"{NativeDll} found but one of its dependencies is missing (libcurl, OpenSSL, or VC++ runtime).", + _ => $"{NativeDll} could not be loaded (Win32 error 0x{w32.NativeErrorCode:X})." + }; + } + catch (Exception ex) + { + return $"Unable to load {NativeDll}: {ex.Message}"; + } + + // success – unload and return null (meaning “no error”) + WindowsDllLoader.Free(h); + return null; + } + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDllResolver.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDllResolver.cs new file mode 100644 index 0000000000..8a127e461d --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/NativeDllResolver.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + /// + /// Ensures AttestationClientLib.dll is resolved from an override path, the app folder, + /// the system directories (System32/SysWOW64), or the default DLL search order (PATH). + /// + internal static class NativeDllResolver + { + private const string NativeDll = "AttestationClientLib.dll"; + private static IntPtr s_module; + + static NativeDllResolver() + { + // 1) Env override (per-job / per-process) + if (TryLoadFromEnv()) + return; + + // 2) App base directory + if (TryLoadFromAppBase()) + return; + + // 3) System directory (System32 for x64 process, SysWOW64 for x86 process) + if (TryLoadFromSystemDir()) + return; + + // 4) Let Windows search PATH / SxS / Known DLL dirs + s_module = WindowsDllLoader.Load(NativeDll); + } + + /// Touch this method from startup code to trigger the static ctor. + internal static void EnsureLoaded() { } + + private static bool TryLoadFromEnv() + { + var overrideDir = Environment.GetEnvironmentVariable("MSAL_MTLSPOP_NATIVE_PATH"); + if (string.IsNullOrWhiteSpace(overrideDir)) + { + return false; + } + + var candidate = Path.Combine(overrideDir, NativeDll); + if (!File.Exists(candidate)) + { + return false; + } + + s_module = WindowsDllLoader.Load(candidate); + return s_module != IntPtr.Zero; + } + + private static bool TryLoadFromAppBase() + { + var exePath = Path.Combine(AppContext.BaseDirectory, NativeDll); + if (!File.Exists(exePath)) + { + return false; + } + + s_module = WindowsDllLoader.Load(exePath); + return s_module != IntPtr.Zero; + } + + private static bool TryLoadFromSystemDir() + { + var windowsRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (string.IsNullOrEmpty(windowsRoot)) + { + return false; + } + + // x64 process -> System32, x86 process -> SysWOW64 + var sysDir = Path.Combine( + windowsRoot, + Environment.Is64BitProcess ? "System32" : "SysWOW64"); + + var sysPath = Path.Combine(sysDir, NativeDll); + if (!File.Exists(sysPath)) + { + return false; + } + + s_module = WindowsDllLoader.Load(sysPath); + return s_module != IntPtr.Zero; + } + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/WindowsDllLoader.cs b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/WindowsDllLoader.cs new file mode 100644 index 0000000000..315ee1e272 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Attestation/WindowsDllLoader.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace Microsoft.Identity.Client.MtlsPop.Attestation +{ + /// + /// Windows‑only helper that loads a native DLL from an absolute path. + /// + internal static class WindowsDllLoader + { + /// + /// Load the DLL and throw when the OS loader fails. + /// + /// Absolute path to AttestationClientLib.dll + /// Module handle (never zero on success). + /// + /// Thrown when kernel32!LoadLibraryW returns NULL. + /// + [DllImport("kernel32", + EntryPoint = "LoadLibraryW", + CharSet = CharSet.Unicode, + SetLastError = true, + ExactSpelling = true)] + private static extern IntPtr LoadLibraryW(string path); + + internal static IntPtr Load(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentNullException(nameof(path)); + + IntPtr h = LoadLibraryW(path); + + if (h == IntPtr.Zero) + { + // Preserve Win32 error code for diagnosis + int err = Marshal.GetLastWin32Error(); + throw new Win32Exception(err, $"Unable to load {path}"); + } + + return h; + } + + /// + /// Optionally expose a Free helper so callers can unload if needed. + /// + [DllImport("kernel32", SetLastError = true)] + private static extern bool FreeLibrary(IntPtr hModule); + + internal static void Free(IntPtr handle) + { + if (handle != IntPtr.Zero) + FreeLibrary(handle); + } + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/IsExternalInit.cs b/src/client/Microsoft.Identity.Client.MtlsPop/IsExternalInit.cs new file mode 100644 index 0000000000..dfb6a17acc --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/IsExternalInit.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#if NETSTANDARD +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit + { + } +} +#endif diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/ManagedIdentityPopExtensions.cs b/src/client/Microsoft.Identity.Client.MtlsPop/ManagedIdentityPopExtensions.cs new file mode 100644 index 0000000000..1c0de4e6bf --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/ManagedIdentityPopExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.MtlsPop +{ + /// + /// Builder‑level opt‑in to Mutual‑TLS Proof‑of‑Possession for all + /// Managed‑Identity token requests produced by this application instance. + /// + /// + /// Requires the Microsoft.Identity.Client.MtlsPop package.
+ ///
+ public static class ManagedIdentityApplicationPopExtension + { + /// + /// Enables mTLS POP across the entire . + /// + public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPossession( + this AcquireTokenForManagedIdentityParameterBuilder builder) + { + builder.CommonParameters.IsManagedIdentityPopEnabled = true; + return builder; + } + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/Microsoft.Identity.Client.MtlsPop.csproj b/src/client/Microsoft.Identity.Client.MtlsPop/Microsoft.Identity.Client.MtlsPop.csproj new file mode 100644 index 0000000000..60159a76fb --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/Microsoft.Identity.Client.MtlsPop.csproj @@ -0,0 +1,64 @@ + + + + + netstandard2.0 + net8.0 + AnyCPU + + + $(TargetFrameworkNetStandard) + + + $(TargetFrameworkNetStandard);$(TargetFrameworkNet) + + + Debug;Release + + + + + $(MsalInternalVersion) + + $(MicrosoftIdentityClientVersion) + + MSAL.NET extension for managed identity proof-of-possession flows + + This package contains binaries needed to use managed identity proof-of-possession (MTLS PoP) flows in applications using MSAL.NET. + + Microsoft Authentication Library Managed Identity MSAL Proof-of-Possession + Microsoft Authentication Library Broker + + + + + true + + + + + + + + + + + + + + + true + true + true + + + + + + + + + + + + diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/PopKeyAttestor.cs b/src/client/Microsoft.Identity.Client.MtlsPop/PopKeyAttestor.cs new file mode 100644 index 0000000000..446a986e6c --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/PopKeyAttestor.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.MtlsPop.Attestation; +using Microsoft.Win32.SafeHandles; + +namespace Microsoft.Identity.Client.MtlsPop +{ + /// + /// Static facade for attesting a KeyGuard/CNG key and getting a JWT back. + /// Key discovery / rotation is the caller's responsibility. + /// + public static class PopKeyAttestor + { + /// + /// Asynchronously attests a KeyGuard/CNG key with the remote attestation service and returns a JWT. + /// Wraps the synchronous in a Task.Run so callers can + /// avoid blocking. Cancellation only applies before the native call starts. + /// + /// Attestation service endpoint (required). + /// Valid SafeNCryptKeyHandle (must remain valid for duration of call). + /// Optional client identifier (may be null/empty). + /// Cancellation token (cooperative before scheduling / start). + public static Task AttestKeyGuardAsync( + string endpoint, + SafeNCryptKeyHandle keyHandle, + string clientId, + CancellationToken cancellationToken = default) + { + if (keyHandle is null || keyHandle.IsInvalid) + throw new ArgumentException("keyHandle must be a valid SafeNCryptKeyHandle", nameof(keyHandle)); + + if (string.IsNullOrWhiteSpace(endpoint)) + throw new ArgumentException("endpoint must be provided", nameof(endpoint)); + + cancellationToken.ThrowIfCancellationRequested(); + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + using var client = new AttestationClient(); + return client.Attest(endpoint, keyHandle, clientId ?? string.Empty); + } + catch (Exception ex) + { + // Map any managed exception to AttestationStatus.Exception for consistency. + return new AttestationResult(AttestationStatus.Exception, string.Empty, -1, ex.Message); + } + }, cancellationToken); + } + } +} diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Shipped.txt @@ -0,0 +1 @@ + diff --git a/src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..a4e6266ba4 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.MtlsPop/PublicApi/PublicAPI.Unshipped.txt @@ -0,0 +1,24 @@ +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationClient +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationClient.Attest(string endpoint, Microsoft.Win32.SafeHandles.SafeNCryptKeyHandle keyHandle, string clientId) -> Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationClient.AttestationClient() -> void +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationClient.Dispose() -> void +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.AttestationResult(Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus Status, string Jwt, int NativeErrorCode, string ErrorMessage) -> void +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.ErrorMessage.get -> string +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.ErrorMessage.init -> void +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.Jwt.get -> string +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.Jwt.init -> void +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.NativeErrorCode.get -> int +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.NativeErrorCode.init -> void +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.Status.get -> Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationResult.Status.init -> void +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus.Exception = 4 -> Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus.NativeError = 1 -> Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus.NotInitialized = 3 -> Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus.Success = 0 -> Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus +Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus.TokenEmpty = 2 -> Microsoft.Identity.Client.MtlsPop.Attestation.AttestationStatus +Microsoft.Identity.Client.MtlsPop.ManagedIdentityApplicationPopExtension +Microsoft.Identity.Client.MtlsPop.PopKeyAttestor +static Microsoft.Identity.Client.MtlsPop.ManagedIdentityApplicationPopExtension.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder +static Microsoft.Identity.Client.MtlsPop.PopKeyAttestor.AttestKeyGuardAsync(string endpoint, Microsoft.Win32.SafeHandles.SafeNCryptKeyHandle keyHandle, string clientId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index e5bd4fdac8..6738da626b 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -38,6 +38,7 @@ internal class AcquireTokenCommonParameters public SortedList>> CacheKeyComponents { get; internal set; } public string FmiPathSuffix { get; internal set; } public string ClientAssertionFmiPath { get; internal set; } + public bool IsManagedIdentityPopEnabled { get; set; } public bool IsMtlsPopRequested { get; set; } internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 3619c73864..69e0e160b4 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -127,6 +127,8 @@ public string Claims } } + public bool IsManagedIdentityPopEnabled => _commonParameters.IsManagedIdentityPopEnabled; + public IAuthenticationOperation AuthenticationScheme => _commonParameters.AuthenticationOperation; public IEnumerable PersistedCacheParameters => _commonParameters.AdditionalCacheParameters; diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs index 67e9590e6a..9c0b799d91 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Collections.Generic; @@ -91,7 +91,7 @@ protected override async Task ExecuteAsync(CancellationTok logger.Info("[ManagedIdentityRequest] Access token retrieved from cache."); try - { + { var proactivelyRefresh = SilentRequestHelper.NeedsRefresh(cachedAccessTokenItem); // If needed, refreshes token in the background @@ -137,7 +137,7 @@ protected override async Task ExecuteAsync(CancellationTok } private async Task GetAccessTokenAsync( - CancellationToken cancellationToken, + CancellationToken cancellationToken, ILoggerAdapter logger) { AuthenticationResult authResult; @@ -157,7 +157,7 @@ private async Task GetAccessTokenAsync( // 1) ForceRefresh is requested // 2) Proactive refresh is in effect // 3) Claims are present (revocation flow) - if (_managedIdentityParameters.ForceRefresh || + if (_managedIdentityParameters.ForceRefresh || AuthenticationRequestParameters.RequestContext.ApiEvent.CacheInfo == CacheRefreshReason.ProactivelyRefreshed || !string.IsNullOrEmpty(_managedIdentityParameters.Claims)) { @@ -194,7 +194,7 @@ private async Task SendTokenRequestForManagedIdentityAsync await ResolveAuthorityAsync().ConfigureAwait(false); - ManagedIdentityClient managedIdentityClient = + ManagedIdentityClient managedIdentityClient = new ManagedIdentityClient(AuthenticationRequestParameters.RequestContext); ManagedIdentityResponse managedIdentityResponse = diff --git a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj index 578bb27e45..b9672089c2 100644 --- a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj +++ b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj @@ -163,4 +163,9 @@ + + + + + diff --git a/src/client/Microsoft.Identity.Client/Properties/InternalsVisibleTo.cs b/src/client/Microsoft.Identity.Client/Properties/InternalsVisibleTo.cs index 769ebfcbf0..d6c67f8270 100644 --- a/src/client/Microsoft.Identity.Client/Properties/InternalsVisibleTo.cs +++ b/src/client/Microsoft.Identity.Client/Properties/InternalsVisibleTo.cs @@ -7,6 +7,7 @@ [assembly: InternalsVisibleTo("Microsoft.Identity.Client.Desktop" + KeyTokens.MSAL)] [assembly: InternalsVisibleTo("Microsoft.Identity.Client.Desktop.WinUI3" + KeyTokens.MSAL)] [assembly: InternalsVisibleTo("Microsoft.Identity.Client.Broker" + KeyTokens.MSAL)] +[assembly: InternalsVisibleTo("Microsoft.Identity.Client.MtlsPop" + KeyTokens.MSAL)] [assembly: InternalsVisibleTo("Microsoft.Identity.Test.Unit" + KeyTokens.MSAL)] [assembly: InternalsVisibleTo("Microsoft.Identity.Test.Common" + KeyTokens.MSAL)] diff --git a/tests/Microsoft.Identity.Test.E2e/KeyGuardAttestationTests.cs b/tests/Microsoft.Identity.Test.E2e/KeyGuardAttestationTests.cs new file mode 100644 index 0000000000..54de6895b6 --- /dev/null +++ b/tests/Microsoft.Identity.Test.E2e/KeyGuardAttestationTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Identity.Client.MtlsPop.Attestation; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using Microsoft.Identity.Client.MtlsPop; +using System.Threading.Tasks; +using System.Threading; + +namespace Microsoft.Identity.Test.E2E +{ + [TestClass] + public class KeyGuardAttestationTests + { + private static CngKey CreateKeyGuardKey(string keyName) + { + const string ProviderName = "Microsoft Software Key Storage Provider"; + const int NCRYPT_USE_VIRTUAL_ISOLATION_FLAG = 0x00020000; + const int NCRYPT_USE_PER_BOOT_KEY_FLAG = 0x00040000; + + var p = new CngKeyCreationParameters + { + Provider = new CngProvider(ProviderName), + ExportPolicy = CngExportPolicies.None, + KeyUsage = CngKeyUsages.AllUsages, + KeyCreationOptions = + CngKeyCreationOptions.OverwriteExistingKey | + (CngKeyCreationOptions)NCRYPT_USE_VIRTUAL_ISOLATION_FLAG | + (CngKeyCreationOptions)NCRYPT_USE_PER_BOOT_KEY_FLAG, + }; + + // Set 2048-bit RSA length + p.Parameters.Add(new CngProperty( + "Length", + BitConverter.GetBytes(2048), + CngPropertyOptions.None)); + + return CngKey.Create(CngAlgorithm.Rsa, keyName, p); + } + + private static bool IsKeyGuardProtected(CngKey key) + { + try + { + // KeyGuard exposes a "Virtual Iso" property that is non-zero when protected. + // Same check used in #5448. :contentReference[oaicite:1]{index=1} + var prop = key.GetProperty("Virtual Iso", CngPropertyOptions.None); + var bytes = prop.GetValue(); + return bytes != null && bytes.Length >= 4 && BitConverter.ToInt32(bytes, 0) != 0; + } + catch + { + return false; + } + } + + [TestCategory("MI_E2E_AzureArc")] + [RunOnAzureDevOps] + [TestMethod] + public void Attest_KeyGuardKey_OnAzureArc_Succeeds() + { + var endpoint = Environment.GetEnvironmentVariable("TOKEN_ATTESTATION_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + Assert.Inconclusive($"Set {"TOKEN_ATTESTATION_ENDPOINT"} on the Azure Arc agent to run this test."); + } + + var clientId = "MSI_CLIENT_ID"; + string keyName = "MsalE2E_Keyguard"; + + CngKey key = null; + try + { + key = CreateKeyGuardKey(keyName); + + if (!IsKeyGuardProtected(key)) + { + Assert.Inconclusive("Key was created but not KeyGuard-protected. Is KeyGuard/VBS enabled on this machine?"); + } + + // Use the new public AttestationClient from the MtlsPop package. :contentReference[oaicite:2]{index=2} + using var client = new AttestationClient(); + var result = client.Attest(endpoint, key.Handle, clientId); + + // Validate success + JWT shape (3 parts). + Assert.AreEqual(AttestationStatus.Success, result.Status, + $"Attestation failed: status={result.Status}, nativeRc={result.NativeErrorCode}, msg={result.ErrorMessage}"); + Assert.IsFalse(string.IsNullOrEmpty(result.Jwt), "Expected a non-empty attestation JWT."); + + var parts = result.Jwt.Split('.'); + Assert.AreEqual(3, parts.Length, "Expected a JWT (3 parts)."); + } + catch (CryptographicException ex) + { + Assert.Inconclusive("CNG/KeyGuard is not available or access is denied on this machine: " + ex.Message); + } + catch (InvalidOperationException ex) + { + // Thrown by AttestationClient when the native DLL cannot be found/initialized. + Assert.Inconclusive("Attestation native lib not available on this runner: " + ex.Message); + } + finally + { + try { key?.Delete(); } catch { /* best-effort cleanup */ } + } + } + + [TestCategory("MI_E2E_AzureArc")] + [RunOnAzureDevOps] + [TestMethod] + public async Task Attest_KeyGuardKey_OnAzureArc_Async_Succeeds() + { + var endpoint = Environment.GetEnvironmentVariable("TOKEN_ATTESTATION_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + Assert.Inconclusive($"Set {"TOKEN_ATTESTATION_ENDPOINT"} on the Azure Arc agent to run this test."); + } + + var clientId = "MSI_CLIENT_ID"; + string keyName = "MsalE2E_Keyguard_Async"; + + CngKey key = null; + try + { + key = CreateKeyGuardKey(keyName); + + if (!IsKeyGuardProtected(key)) + { + Assert.Inconclusive("Key was created but not KeyGuard-protected. Is KeyGuard/VBS enabled on this machine?"); + } + + // Exercise the async facade (PopKeyAttestor) which wraps the synchronous native call in Task.Run. + var result = await PopKeyAttestor.AttestKeyGuardAsync( + endpoint, + key.Handle, + clientId: clientId, + cancellationToken: CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(AttestationStatus.Success, result.Status, + $"Async attestation failed: status={result.Status}, nativeRc={result.NativeErrorCode}, msg={result.ErrorMessage}"); + Assert.IsFalse(string.IsNullOrEmpty(result.Jwt), "Expected a non-empty attestation JWT from async path."); + + var parts = result.Jwt.Split('.'); + Assert.AreEqual(3, parts.Length, "Expected a JWT (3 parts) from async path."); + } + catch (CryptographicException ex) + { + Assert.Inconclusive("CNG/KeyGuard is not available or access is denied on this machine: " + ex.Message); + } + catch (InvalidOperationException ex) + { + // Could originate from native initialization inside PopKeyAttestor (AttestationClient constructor). + Assert.Inconclusive("Attestation native lib not available on this runner (async path): " + ex.Message); + } + catch (ArgumentException ex) + { + Assert.Inconclusive("Handle or parameters invalid for async attestation path: " + ex.Message); + } + finally + { + try { key?.Delete(); } catch { /* best-effort cleanup */ } + } + } + } +} diff --git a/tests/Microsoft.Identity.Test.E2e/Microsoft.Identity.Test.E2E.MSI.csproj b/tests/Microsoft.Identity.Test.E2e/Microsoft.Identity.Test.E2E.MSI.csproj index 6b85de84ce..11833e9e31 100644 --- a/tests/Microsoft.Identity.Test.E2e/Microsoft.Identity.Test.E2E.MSI.csproj +++ b/tests/Microsoft.Identity.Test.E2e/Microsoft.Identity.Test.E2E.MSI.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/KeyGuardAttestation.csproj b/tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/KeyGuardAttestation.csproj new file mode 100644 index 0000000000..9485e36af6 --- /dev/null +++ b/tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/KeyGuardAttestation.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/Program.cs b/tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/Program.cs new file mode 100644 index 0000000000..35a0bcce80 --- /dev/null +++ b/tests/devapps/Managed Identity apps/KeyGuardAttestationApp/KeyGuardAttestation/Program.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography; +using Microsoft.Identity.Client.MtlsPop.Attestation; + +class Program +{ + static void Main(string[] args) + { + var endpoint = Environment.GetEnvironmentVariable("TOKEN_ATTESTATION_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + { + Console.WriteLine("TOKEN_ATTESTATION_ENDPOINT not set."); + return; + } + Console.WriteLine($"Endpoint = {endpoint}"); + + string clientId = Environment.GetEnvironmentVariable("MSI_CLIENT_ID") ?? "Test_Client_Id"; + Console.WriteLine($"ClientId = {clientId}"); + + // Create a KeyGuard key + string keyName = "TestKey_" + Guid.NewGuid().ToString("N"); + const string ProviderName = "Microsoft Software Key Storage Provider"; + const int NCRYPT_USE_VIRTUAL_ISOLATION_FLAG = 0x00020000; + const int NCRYPT_USE_PER_BOOT_KEY_FLAG = 0x00040000; + + var p = new CngKeyCreationParameters + { + Provider = new CngProvider(ProviderName), + ExportPolicy = CngExportPolicies.None, + KeyUsage = CngKeyUsages.AllUsages, + KeyCreationOptions = + CngKeyCreationOptions.OverwriteExistingKey | + (CngKeyCreationOptions)NCRYPT_USE_VIRTUAL_ISOLATION_FLAG | + (CngKeyCreationOptions)NCRYPT_USE_PER_BOOT_KEY_FLAG, + }; + p.Parameters.Add(new CngProperty("Length", BitConverter.GetBytes(2048), CngPropertyOptions.None)); + + using var key = CngKey.Create(CngAlgorithm.Rsa, keyName, p); + + // Try attestation + try + { + using var client = new AttestationClient(); + var result = client.Attest(endpoint, key.Handle, clientId); + + Console.WriteLine($"Status = {result.Status}, NativeRc = {result.NativeErrorCode}"); + if (!string.IsNullOrEmpty(result.Jwt)) + { + Console.WriteLine(string.Concat("JWT (truncated): ", result.Jwt.AsSpan(0, 50), "...")); + } + else + { + Console.WriteLine($"Error: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Console.WriteLine("Exception: " + ex); + } + finally + { + try + { CngKey.Open(keyName, new CngProvider(ProviderName)).Delete(); } + catch { } + } + } +} + diff --git a/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/ManagedIdentityAppVM.csproj b/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/ManagedIdentityAppVM.csproj index 150d7f869c..ea3a7b6aec 100644 --- a/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/ManagedIdentityAppVM.csproj +++ b/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/ManagedIdentityAppVM.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/Program.cs b/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/Program.cs index 427b7ca149..f9f72091a9 100644 --- a/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/Program.cs +++ b/tests/devapps/Managed Identity apps/ManagedIdentityAppVM/Program.cs @@ -4,6 +4,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.AppConfig; using Microsoft.IdentityModel.Abstractions; +using Microsoft.Identity.Client.MtlsPop; IIdentityLogger identityLogger = new IdentityLogger(); @@ -20,6 +21,7 @@ try { var result = await mi.AcquireTokenForManagedIdentity(scope) + .WithMtlsProofOfPossession() .ExecuteAsync().ConfigureAwait(false); Console.WriteLine("Success");