Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- Fixed `WithExtraQueryParameters` on `ManagedIdentityApplicationBuilder` bypassing token caching. [#6035](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6035)
- Guarded HTTP status codes on discovery endpoints in `KnownInstanceMetadataIsUpToDateAsync`. [#6048](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6048)
- Detect orphaned KeyGuard certificates via public-key modulus comparison. [#6020](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6020)
- KeyGuard/Credential Guard attestation failures now surface as an `MsalServiceException("attestation_failed")` (carrying the MAA status/native error/reason) instead of silently sending a non-attested certificate request to IMDS, and native `AttestationClientLib` (MAA) logs are bridged into the MSAL `ILoggerAdapter`. **Behavior change:** when an attestation provider is configured via `WithAttestationSupport()` and returns no token for a KeyGuard key, the request now fails closed rather than falling back to a non-attested request. [#6081](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/6081)

4.84.1
======
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Runtime.InteropServices;
using Microsoft.Identity.Client.Core;
using Microsoft.Win32.SafeHandles;

namespace Microsoft.Identity.Client.KeyAttestation.Attestation
Expand All @@ -15,19 +16,33 @@ internal sealed class AttestationClient : IDisposable
{
private bool _initialized;

// Strong reference to the native log callback for the lifetime of this client. The native library
// stores the function pointer during InitAttestationLib and invokes it until UninitAttestationLib;
// keeping the delegate referenced here prevents the GC from collecting it (which would crash the
// native callback).
private readonly AttestationClientLib.LogFunc _logBridge;

/// <summary>
/// AttestationClient constructor. Relies on the default OS loader to locate the native DLL.
/// </summary>
/// <param name="logger">
/// Optional MSAL logger. When supplied, native <c>AttestationClientLib.dll</c> log lines are
/// forwarded into the MSAL logging pipeline so MAA diagnostics appear in MSAL logs. When null,
/// native logs fall back to <see cref="System.Diagnostics.Trace"/>.
/// </param>
/// <exception cref="InvalidOperationException"></exception>
public AttestationClient()
public AttestationClient(ILoggerAdapter logger = null)
{
string dllError = NativeDiagnostics.ProbeNativeDll();
// intentionally not throwing on dllError

// Bridge native logs into the MSAL logger when available; otherwise Trace.
_logBridge = AttestationLogger.CreateLoggerBridge(logger);

// Load & initialize (logger is required by native lib)
var info = new AttestationClientLib.AttestationLogInfo
{
Log = AttestationLogger.ConsoleLogger,
Log = _logBridge,
Ctx = IntPtr.Zero
};

Expand Down Expand Up @@ -58,7 +73,13 @@ public AttestationResult Attest(string endpoint,
endpoint, null, null, keyHandle, out buf, clientId);

if (rc != 0)
return new(AttestationStatus.NativeError, null, null, rc, null);
{
// Translate the native return code into a human-readable reason so the failure
// (e.g. MAA policy evaluation / dbx validation) propagates back to the caller
// instead of being silently dropped.
string reason = AttestationErrors.Describe((AttestationResultErrorCode)rc);
Comment thread
gladjohn marked this conversation as resolved.
return new(AttestationStatus.NativeError, null, null, rc, reason);
}

if (buf == IntPtr.Zero)
return new(AttestationStatus.TokenEmpty, null, null, 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Identity.Client.Core;

namespace Microsoft.Identity.Client.KeyAttestation.Attestation
{
Expand All @@ -16,11 +17,7 @@ internal static class AttestationLogger
{
try
{
string sTag = ToText(tag);
string sFunc = ToText(func);
string sMsg = ToText(msg);

var lineText = $"[MtlsPop][{lvl}] {sTag} {sFunc}:{line} {sMsg}";
var lineText = FormatNativeLog(tag, lvl, func, line, msg);

// Default: Trace (respects listeners; safe for all app types)
Trace.WriteLine(lineText);
Expand All @@ -30,6 +27,86 @@ internal static class AttestationLogger
}
};

/// <summary>
/// Builds a native log callback (<see cref="AttestationClientLib.LogFunc"/>) that forwards
/// <c>AttestationClientLib.dll</c> log lines into the MSAL <see cref="ILoggerAdapter"/> so that
/// native MAA diagnostics (e.g. policy-evaluation failures) appear in MSAL logs instead of only
/// going to <see cref="Trace"/>. When <paramref name="logger"/> is null, falls back to the
/// <see cref="ConsoleLogger"/> (Trace) so behavior is preserved for callers without a logger.
/// </summary>
/// <remarks>
/// The returned delegate must never throw: a managed exception escaping back into the native
/// caller would corrupt the interop boundary. All work is wrapped in a try/catch that swallows.
/// Callers must keep a strong reference to the returned delegate for as long as the native library
/// holds the function pointer (i.e. until <c>UninitAttestationLib</c>), otherwise the GC may collect
/// it and the native callback will crash.
/// </remarks>
internal static AttestationClientLib.LogFunc CreateLoggerBridge(ILoggerAdapter logger)
{
if (logger is null)
{
return ConsoleLogger;
}

return (ctx, tag, lvl, func, line, msg) =>
{
try
{
LogLevel mapped = MapLevel(lvl);
if (!logger.IsLoggingEnabled(mapped))
{
return;
}

string lineText = FormatNativeLog(tag, lvl, func, line, msg);

// Native Error/Warning/Info lines (MAA policy-evaluation failures, endpoint
// URLs, TPM cert issuer) are operational diagnostics, not user PII, so they are
// logged as the scrubbed message and stay visible in standard MSAL logs. The
// most verbose native output (Debug -> Verbose, and any unknown level) can carry
// richer payload fragments, so it is routed through the PII slot and only
// surfaces when the caller opts into PII logging.
if (mapped == LogLevel.Verbose)
{
logger.Log(mapped, lineText, string.Empty);
}
else
{
logger.Log(mapped, string.Empty, lineText);
}
}
catch
{
// A logging callback must never throw back into native code.
}
};
}

/// <summary>
/// Maps a native <see cref="AttestationClientLib.LogLevel"/> to the MSAL <see cref="LogLevel"/>.
/// Unknown values map to <see cref="LogLevel.Verbose"/>.
/// </summary>
internal static LogLevel MapLevel(AttestationClientLib.LogLevel lvl) => lvl switch
{
AttestationClientLib.LogLevel.Error => LogLevel.Error,
AttestationClientLib.LogLevel.Warn => LogLevel.Warning,
AttestationClientLib.LogLevel.Info => LogLevel.Info,
AttestationClientLib.LogLevel.Debug => LogLevel.Verbose,
_ => LogLevel.Verbose,
};

/// <summary>
/// Formats a single native log line into a human-readable string, robust to null/IntPtr inputs.
/// </summary>
internal static string FormatNativeLog(string tag, AttestationClientLib.LogLevel lvl, string func, int line, string msg)
{
string sTag = ToText(tag);
string sFunc = ToText(func);
string sMsg = ToText(msg);

return $"[MtlsPop][AttestationClientLib][{lvl}] {sTag} {sFunc}:{line} {sMsg}";
}

// Converts either string or IntPtr (char*) to text. Works with any LogFunc variant.
private static string ToText(object value)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.KeyAttestation.Attestation;

namespace Microsoft.Identity.Client.KeyAttestation
{
Expand All @@ -12,6 +13,11 @@ namespace Microsoft.Identity.Client.KeyAttestation
/// </summary>
public static class ManagedIdentityAttestationExtensions
{
// Error code surfaced when Credential Guard / KeyGuard attestation is requested but fails.
// The failure originates from the attestation (MAA) service, so it is surfaced as a service error.
// Matches the error code used by the IMDSv2 consumer so callers see a single, consistent code.
private const string AttestationFailedErrorCode = "attestation_failed";

/// <summary>
/// Enables Credential Guard attestation support for managed identity mTLS Proof-of-Possession flows.
/// This method should be called after <see cref="ManagedIdentityPopExtensions.WithMtlsProofOfPossession"/>.
Expand All @@ -28,16 +34,31 @@ public static AcquireTokenForManagedIdentityParameterBuilder WithAttestationSupp

builder.CommonParameters.AttestationTokenProvider = async (endpoint, keyHandle, clientId, keyId, logger, ct) =>
{
var result = await PopKeyAttestor.AttestCredentialGuardAsync(
AttestationResult result = await PopKeyAttestor.AttestCredentialGuardAsync(
endpoint,
keyHandle,
clientId,
keyId,
logger,
ct).ConfigureAwait(false);

// Return JWT on success, null for non-attested flow on failure
return result.Status == Attestation.AttestationStatus.Success ? result.Jwt : null;
if (result.Status == AttestationStatus.Success && !string.IsNullOrEmpty(result.Jwt))
Comment thread
Copilot marked this conversation as resolved.
Outdated
{
return result.Jwt;
}

// Attestation failed. Surface the reason to the caller instead of returning null —
// returning null would cause an empty/non-attested certificate request to be sent to
// IMDS, silently dropping the real failure (e.g. an MAA policy-evaluation deny). The
// failure originates from the attestation (MAA) service, so it is a service exception.
string reason = string.IsNullOrEmpty(result.ErrorMessage)
? "(no additional detail available)"
: result.ErrorMessage;

throw new MsalServiceException(
AttestationFailedErrorCode,
$"Key Guard attestation failed; no attestation token was produced. " +
$"Status: {result.Status}, NativeErrorCode: {result.NativeErrorCode}, Reason: {reason}");
};

return builder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public static async Task<AttestationResult> AttestCredentialGuardAsync(
{
try
{
using var client = new AttestationClient();
using var client = new AttestationClient(logger);
return client.Attest(endpoint, safeNCryptKeyHandle, clientId ?? string.Empty);
}
catch (Exception ex)
Expand Down Expand Up @@ -183,7 +183,7 @@ public static async Task<AttestationResult> AttestCredentialGuardAsync(
{
try
{
using var client = new AttestationClient();
using var client = new AttestationClient(logger);
return client.Attest(endpoint, safeNCryptKeyHandle, clientId ?? string.Empty);
}
catch (Exception ex)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -490,18 +490,41 @@ private async Task<string> GetAttestationJwtAsync(

if (string.IsNullOrWhiteSpace(attestationJwt))
Comment thread
gladjohn marked this conversation as resolved.
{
_requestContext.Logger.Info("[ImdsV2] Attestation provider returned null/empty JWT. Proceeding with non-attested flow.");
return null;
// A provider was configured (we passed the _attestationTokenProvider null-check above)
// and this is a KeyGuard key, for which attestation is mandatory. A null/empty token
// means attestation did not succeed — refuse to fall back to a non-attested request,
// which would silently send an empty attestation token to IMDS.
throw MsalServiceExceptionFactory.CreateManagedIdentityException(
Comment thread
gladjohn marked this conversation as resolved.
"attestation_failed",
"[ImdsV2] Attestation was required for this KeyGuard key but the attestation provider " +
"returned no token. Refusing to send a non-attested certificate request to IMDS.",
null,
ManagedIdentitySource.Imds,
null);
}

return attestationJwt;
}
catch (MsalServiceException)
{
// Already a well-formed attestation service failure (from the provider or the check above) —
// propagate as-is rather than double-wrapping.
throw;
}
catch (OperationCanceledException)
{
// Cancellation (including TaskCanceledException) must propagate unchanged rather than
// being masked as an attestation_failed service error.
throw;
}
catch (Exception ex)
Comment thread
gladjohn marked this conversation as resolved.
{
throw new MsalClientException(
throw MsalServiceExceptionFactory.CreateManagedIdentityException(
"attestation_failed",
$"[ImdsV2] Attestation token provider failed: {ex.Message}",
ex);
ex,
ManagedIdentitySource.Imds,
null);
}
}

Expand Down
Loading
Loading