docs(msal-node): mTLS PoP cross-SDK implementation standard#8556
docs(msal-node): mTLS PoP cross-SDK implementation standard#8556Robbie-Microsoft wants to merge 2 commits into
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ation-standard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||
| ### HTTP Client Injection | ||
|
|
||
| A key architectural change for mTLS PoP: MSAL must control (or be injected with) the HTTP client used for the token request, because it must present the client certificate during the TLS handshake with the STS. For MSI flows specifically, this applies only to the second leg (IMDS → ESTS token request) — a custom HTTP client can be used for the first leg (calls made within IMDS, which require no client certificate). MSAL uses its own internal transport for the cert-authenticated ESTS leg because the private key is non-exportable and cannot be surfaced to an external HTTP client. |
There was a problem hiding this comment.
What parts of this apply to IMDS users (3p) and which to IMDS itself (1p)?
There was a problem hiding this comment.
Nothing in this paragraph applies to IMDS itself - IMDS is just the endpoint MSAL calls on leg 1. The entire description is about what a 3p MSAL consumer can and cannot inject.
There was a problem hiding this comment.
Ah, then I was confused by "IMDS → ESTS token request" and "calls made within IMDS"
|
|
||
| **MSAL.NET is the reference implementation.** It ships mTLS PoP for both the Managed Identity path (`ManagedIdentityApplication.AcquireTokenForManagedIdentity().WithMtlsProofOfPossession()`) and the Confidential Client / SNI path (`ConfidentialClientApplication.AcquireTokenForClient().WithMtlsProofOfPossession()`). The goal of this document is to standardize the same behavior across all MSAL SDKs. | ||
|
|
||
| **The downstream problem is the key challenge.** After acquiring an mTLS PoP token, the developer must make downstream resource calls *over mTLS using the same binding certificate* — the resource server checks that the TLS client certificate matches the public key embedded in the token. When the binding key is stored in Windows KeyGuard (VBS-protected), standard TLS stacks like OpenSSL cannot perform this handshake because CNG refuses to export the key. See [Part 3](#part-3--downstream-mtls-calls-the-core-problem) for the full analysis. |
There was a problem hiding this comment.
A definition of "CNG" and maybe a link to some docs for it would help
There was a problem hiding this comment.
https://learn.microsoft.com/en-us/windows/win32/seccng/cng-portal
CNG (Cryptography API: Next Generation) is Microsoft's modern cryptographic framework in Windows, replacing the legacy CryptoAPI. It provides:
- Key storage providers (KSPs) — including the KeyGuard/VBS provider that keeps keys in virtualization-based security
- NCryptSignHash() — signs data using a key handle without ever exposing raw key bytes
- NCryptExportKey() — exports keys (blocked for non-exportable KeyGuard keys)
In the mTLS PoP context, CNG is the gatekeeper. TLS stacks that can talk to CNG via key handles (Schannel, WinHTTP, Go's crypto.Signer) work with KeyGuard keys, while stacks that need raw key bytes (OpenSSL, used by Node.js/Python/Java) cannot.
| MSAL generates a key inside Windows Virtualization-Based Security (VBS) KeyGuard. The key material is **never** accessible outside CNG (`NCryptSignHash` only). Attestation via MAA is an integral part of this path — KeyGuard without attestation provides minimal security value. This is the complex scenario: attestation requires a native `AttestationClientLib.dll` distributed separately, and downstream BYO HTTP client is **not possible** for most SDKs (see [Part 3](#part-3--downstream-mtls-calls-the-core-problem)). | ||
|
|
||
| **Roadmap — software / exportable keys (simpler path):** | ||
| MSAL generates a software RSA key; key material can be exported as PEM bytes. No attestation is required. Downstream BYO HTTP client is **possible** for all SDKs because the raw key bytes can be passed to any TLS stack. This path is not yet end-to-end testable — Entra STS accepts software-key mTLS PoP tokens, but IMDS (MIRP) does not yet issue software-key binding certificates, so the full flow cannot be validated. |
There was a problem hiding this comment.
This looks like a much easier path for everyone; could we start here?
There was a problem hiding this comment.
Agree - software keys are the simpler path for everyone. We have an active ask to the IMDS team to light this up.
| **Roadmap — software / exportable keys (simpler path):** | ||
| MSAL generates a software RSA key; key material can be exported as PEM bytes. No attestation is required. Downstream BYO HTTP client is **possible** for all SDKs because the raw key bytes can be passed to any TLS stack. This path is not yet end-to-end testable — Entra STS accepts software-key mTLS PoP tokens, but IMDS (MIRP) does not yet issue software-key binding certificates, so the full flow cannot be validated. | ||
|
|
||
| > **Critical**: Downstream transport feasibility depends on the **key model**. A KeyGuard key cannot be used with standard BYO HTTP clients regardless of whether attestation is enabled. |
There was a problem hiding this comment.
My understanding is that Py, Java and JS rely on OpenSSL on Windows for crypto. And Open SSL doesn't KeyGuard crypto operations (namely opening mTLS channel with cert that has private key in KeyGuard). But Golang seems to work?
There was a problem hiding this comment.
msal-go wraps the CNG key handle in a crypto.Signer implementation that calls NCryptSignHash() under the hood, so the TLS CertificateVerify step is performed entirely through CNG without ever exporting key bytes. It's architecturally equivalent to what Schannel/.NET does.
There was a problem hiding this comment.
You are correct.
Go works because its TLS stack is pluggable via crypto.Signer in a way that OpenSSL-backed runtimes are not.
|
|
||
| All SDKs set `tokenType` / `token_type` to `"mtls_pop"`. | ||
|
|
||
| **Authorization header:** All SDKs use `Authorization: mtls_pop <access_token>` for downstream resource calls. msal-dotnet provides `result.CreateAuthorizationHeader()`; other SDKs require manual construction. |
There was a problem hiding this comment.
Why the need for manual construction in non-.NET languages? Can an authZ header helper be provided in other languages too?
There was a problem hiding this comment.
CreateAuthorizationHeader() is a trivial one-liner ("mtls_pop " + accessToken) - there's no technical reason it can't be added to every MSAL SDK. It just hasn't been done yet since the feature is new. We should add equivalent helpers across all SDKs as a follow-up.
There was a problem hiding this comment.
If it's as simple as token.kind + token.string() I'd leave it to callers
|
|
||
| - **Current**: the developer must manually place `AttestationClientLib.dll` alongside the MSAL native addon (e.g., in `bin/win-x64/`). Each MSAL team tracks a follow-up to provide a setup script that downloads the DLL from the appropriate NuGet package, unzips it, and places it correctly. | ||
| - **Ownership**: each MSAL SDK team owns the setup script for their respective package. | ||
| - **Long-term goal**: the MAA team will produce first-class native packages (npm, pip, Maven, etc.) so that `AttestationClientLib.dll` can be declared as a standard package dependency — eliminating manual bin-placement entirely. |
There was a problem hiding this comment.
This won't eliminate manual bin placement for all languages (e.g. Go)
There was a problem hiding this comment.
Good catch - that's accurate. Go's module system has no mechanism for distributing native binaries, so there's no Go equivalent of an npm/pip/Maven package that could carry AttestationClientLib.dll. Even if MAA publishes native packages for every other ecosystem, Go developers would still need to manually obtain and place the DLL.
| |---|---|---| | ||
| | **msal-dotnet** | `.WithAttestationSupport()` — extension method in the separate `Microsoft.Identity.Client.KeyAttestation` NuGet package | `Microsoft.Identity.Client.KeyAttestation` NuGet (wraps native `Microsoft.Azure.Security.KeyGuardAttestation.dll`) | | ||
| | **msal-go** | Automatic — key type is selected at runtime (KeyGuard → Hardware → InMemory fallback); KeyGuard path triggers attestation when `AttestationClientLib.dll` is present; no explicit API flag | `AttestationClientLib.dll` | | ||
| | **msal-java** | `withAttestation: true` boolean parameter in `MtlsMsiClient.acquireToken()` | `AttestationClientLib.dll` on system `PATH` | |
There was a problem hiding this comment.
The fact that we don't have a maven package for the attestation package is not great. We need the MAA or KeyGuard team to produce one. If there are security issues, CVEs cannot alert bin-placed DLLs.
|
|
||
| After acquiring an mTLS PoP token, the developer must make downstream resource calls **over mTLS using the same binding certificate**. This is where the key model critically matters. | ||
|
|
||
| Any TLS stack performing a client certificate handshake must have signing access to the private key for the `CertificateVerify` step in the TLS exchange — either through raw key material (as OpenSSL-style stacks require) or through a platform/provider-backed key handle (as Schannel, WinHTTP, and Go's `crypto.Signer` provide). When the private key is stored in Windows KeyGuard (VBS-protected), the CNG key storage provider rejects all export operations, so only stacks with a CNG-handle path can work: |
There was a problem hiding this comment.
It would help to clarify who's responsible for ensuring the resource request handshake succeeds i.e., that the resource client is able to use the certificate, and outline how that should be done
There was a problem hiding this comment.
agreed we need a devex story here -
- KeyGuard path (current): For msal-dotnet and msal-go, the Azure SDK story is clear , you can configure your own HTTP client with the binding certificate from AuthenticationResult.
- For msal-java, msal-node, and msal-python, MSAL m,ay need to own the downstream transport via
helpers (e.g., MtlsMsiClient.httpRequest(), sendGetRequestAsync()) because the developer's HTTP client can't use the non-exportable key. - Software key path (roadmap): The Azure SDK / developer is universally responsible — MSAL returns the exportable key + cert in AuthenticationResult
|
|
||
| #### Developer experience for `AttestationClientLib.dll` bin-placement | ||
|
|
||
| - **Current**: the developer must manually place `AttestationClientLib.dll` alongside the MSAL native addon (e.g., in `bin/win-x64/`). Each MSAL team tracks a follow-up to provide a setup script that downloads the DLL from the appropriate NuGet package, unzips it, and places it correctly. |
There was a problem hiding this comment.
Some more details would help here. Is the destination path global? Does it vary across MSALs? How will users find it?
There was a problem hiding this comment.
Good point - the path is not global and varies by SDK. For msal-dotnet, NuGet handles placement automatically. For Go and Node.js, Windows' standard DLL search order applies (same directory as the executable/addon, or PATH). For Java, PATH is required. We'll add per-SDK specifics to the subsection. The setup scripts we're tracking will handle the correct destination per package.
There was a problem hiding this comment.
Remember to threat model DLL preloading attacks
|
|
||
| - **Current**: the developer must manually place `AttestationClientLib.dll` alongside the MSAL native addon (e.g., in `bin/win-x64/`). Each MSAL team tracks a follow-up to provide a setup script that downloads the DLL from the appropriate NuGet package, unzips it, and places it correctly. | ||
| - **Ownership**: each MSAL SDK team owns the setup script for their respective package. | ||
| - **Long-term goal**: the MAA team will produce first-class native packages (npm, pip, Maven, etc.) so that `AttestationClientLib.dll` can be declared as a standard package dependency — eliminating manual bin-placement entirely. |
There was a problem hiding this comment.
Realistically, how far out would this long-term goal be accomplished? The manual bin placement approach feels archaic.
| - MSAL's built-in transport helpers become optional convenience wrappers rather than mandatory requirements | ||
| - `AttestationClientLib.dll` and native attestation dependencies are not required | ||
|
|
||
| **Current status**: IMDS (MIRP) does not yet issue software-key binding certificates, making this path untestable end-to-end. Entra STS accepts software-key mTLS PoP tokens, but without IMDS support the full MSI flow cannot be exercised. This is on the roadmap — there is an active ask to the IMDS team to enable software-key issuance. Until that pivot lands, **use MSAL's SDK-provided transport for downstream calls on msal-java, msal-node, and msal-python**, and BYO HTTP client on msal-dotnet and msal-go. |
There was a problem hiding this comment.
"Untestable" implies this path is possible but the rest of the text presents it as impossible because IMDS is missing a feature. Do we have any idea when that feature will be implemented?
There was a problem hiding this comment.
Good catch - "untestable" is misleading. The path is not yet possible (not merely hard to test): IMDS does not issue software-key binding certs, so the flow cannot complete. Will change to "this path is not yet functional end-to-end."
And the sentence would read:
Current status: IMDS (MIRP) does not yet issue software-key binding certificates, so this path is not yet functional end-to-end. Entra STS accepts software-key mTLS PoP tokens, but without IMDS support the full MSI flow cannot complete. This is on the roadmap - there is an active ask to the IMDS team to enable software-key issuance.
| | **msal-go** | Path 1 (SNI): `confidential.WithMtlsProofOfPossession()` on `AcquireTokenByCredential()`<br/>Path 2 (MSI): `managedidentity.WithMtlsProofOfPossession()` on `AcquireToken()` | | ||
| | **msal-java** | Path 1 (SNI): `ClientCredentialParameters.builder(scopes).withMtlsProofOfPossession().build()` on `app.acquireToken()`<br/>Path 2 (MSI): `new MtlsMsiClient().acquireToken(resource, identityType, identityId, withAttestation, correlationId)` | | ||
| | **msal-node** | Path 1 (SNI): `app.acquireTokenByClientCredential({ scopes, authenticationScheme: AuthenticationScheme.MTLS_POP })` — `@azure/msal-node`<br/>Path 2 (MSI): `new MtlsManagedIdentityApplication().acquireToken({ resource })` — `@azure/msal-node-mtls-extensions` | | ||
| | **msal-python** | TBD | |
There was a problem hiding this comment.
Is there no API yet for python, or just the doc hasn't been updated for Python yet?
There was a problem hiding this comment.
The Python implementation is not finished yet. CC @gladjohn
| | **msal-node** | WinHTTP + Schannel (N-API addon) | ❌ **Not possible** — Node.js/OpenSSL needs raw key bytes; CNG refuses export for KeyGuard keys | `MtlsManagedIdentityApplication.sendGetRequestAsync()` / `sendPostRequestAsync()` — `@azure/msal-node-mtls-extensions` | | ||
| | **msal-python** | WinHTTP + Schannel (ctypes) | ❌ **Not possible** — same CNG constraint | ❌ **Not yet implemented** | | ||
|
|
||
| > **Default stance for msal-java, msal-node, msal-python**: With the current KeyGuard architecture, downstream BYO HTTP client use is **not possible**. This is a fundamental architectural constraint — the key cannot be extracted from CNG. SDK-provided transports are the only workaround where available; for msal-python, no downstream helper exists yet. |
There was a problem hiding this comment.
for msal-python, no downstream helper exists yet
Is the work to create this downstream helper already in progress?
There was a problem hiding this comment.
It's just that we haven't finished the msal-python implementation yet. CC @gladjohn
There was a problem hiding this comment.
The msal-python mTLS PoP implementation currently covers token acquisition via WinHTTP/Schannel (ctypes). We have a POC for the downstream API helper. I can share the POC on this one.
|
Reminder: This PR appears to be stale. If this PR is still a work in progress please mark as draft. |
Summary
Adds
mtls-pop-cross-sdk-implementation-standard.md— a reference document for the Azure SDK team and MSAL maintainers covering the mTLS Proof-of-Possession implementation standard across all MSAL SDKs.Contents
GetManagedIdentitySourceAsync()AuthenticationResultbinding certificate fields by SDKGetManagedIdentitySourceAsync()(msal-dotnet)ClientAssertionCredential(stub; separate doc TBD)Reviewers
This document has been reviewed by @bgavrilMS and @gladjohn.