Skip to content

Implement mTLS Proof-of-Possession (mTLS PoP) token acquisition#1021

Open
Robbie-Microsoft wants to merge 9 commits intodevfrom
rginsburg/mtls_pop
Open

Implement mTLS Proof-of-Possession (mTLS PoP) token acquisition#1021
Robbie-Microsoft wants to merge 9 commits intodevfrom
rginsburg/mtls_pop

Conversation

@Robbie-Microsoft
Copy link
Copy Markdown

@Robbie-Microsoft Robbie-Microsoft commented Apr 8, 2026

mTLS Proof of Possession (mTLS PoP)

Implements mTLS Proof of Possession token acquisition for two scenarios, matching MSAL.NET's mTLS PoP implementation:

  • Path 1 (Confidential Client / SNI) — acquire an mTLS PoP token using a caller-supplied certificate presented during the TLS handshake to a regional mTLS endpoint
  • Path 2 (Managed Identity / IMDSv2) — acquire an mTLS PoP token on an Azure VM using an IMDS-issued binding certificate backed by a VBS KeyGuard-protected CNG key, accessed via JNA — no .NET runtime or subprocess required

Path 1 — Confidential Client

New option: ClientCredentialParameters.withMtlsProofOfPossession() on acquireToken.

The caller provides a certificate (PKCS12, PEM, or hardware-backed PKCS11) as the client credential. MSAL builds a custom SSLSocketFactory and presents the certificate during the TLS handshake to the regional mTLS endpoint — no
client_assertion JWT is included in the request body. The token is cached and discriminated by the certificate's x5t#S256 thumbprint so different certificates never share cache entries.

IClientCertificate cert = ClientCredentialFactory.createFromCertificate(
    new FileInputStream("cert.p12"), "password");

ConfidentialClientApplication app = ConfidentialClientApplication
    .builder("client-id", cert)
    .authority("https://login.microsoftonline.com/{tenantId}")
    .azureRegion("eastus")
    .build();

IAuthenticationResult result = app.acquireToken(
    ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default"))
        .withMtlsProofOfPossession()
        .build()
).get();
// result.bindingCertificate()        — the cert bound to the token
// result.metadata().tokenSource()    — Cache on repeat calls

Path 2 — Managed Identity (IMDSv2)

New class: MtlsMsiClient in the msal4j-mtls-extensions module.

Fully automated — no certificates or keys to manage. MSAL handles the complete flow via JNA (ncrypt.dll) directly from the JVM:

Step What happens
1. Platform metadata GET /metadata/identity/getplatformmetadataclientId, tenantId, cuId, attestationEndpoint
2. CNG key NCryptCreatePersistedKey — 3-level fallback: KeyGuard (VBS) → Hardware (Software KSP) → InMemory
3. CSR Pure-Java PKCS#10 ASN.1 matching MSAL.NET's Csr.Generate(): CN={clientId} DC={tenantId}, RSASSA-PSS SHA-256, CuID OID 1.3.6.1.4.1.311.90.2.10 in attributes [0]
4. Attestation AttestationClientLib.dll → MAA JWT proving KeyGuard key protection (Trusted Launch VMs only)
5. Credential issuance POST /metadata/identity/issuecredential → binding cert from managedidentitysnissuer.login.microsoft.com
6. Token POST {mtlsEndpoint}/{tenantId}/oauth2/v2.0/token — TLS handshake via CngSignatureSpi (key never leaves CNG)
MtlsMsiClient client = new MtlsMsiClient();

MtlsMsiHelperResult result = client.acquireToken(
    "https://graph.microsoft.com",  // resource
    "SystemAssigned",               // identityType
    null,                           // identityId (null = system-assigned)
    true,                           // withAttestation
    null                            // correlationId
);
// result.getAccessToken()          — mtls_pop token
// result.getBindingCertificate()   — IMDS-issued cert; thumbprint matches cnf.x5t#S256
// Second call → cert cache hit, then token cache hit

Caching

Path 1 Path 2
Token cache key token_type=mtls_pop + cert x5t#S256 token_type=mtls_pop + cert x5t#S256
Cert cache N/A — caller manages In-memory, 5-min pre-expiry buffer
CNG key N/A Persisted as MSALMtlsKey_{cuId} (Software KSP, USER scope)

Requirements

Path 1 Path 2
Platform Any Windows only (CNG + VBS via JNA)
Azure App registration with cert Azure VM with Managed Identity
VM Trusted Launch + Credential Guard active
Runtime Java 8+ Java 8+ — no .NET required

Note

https://management.azure.com may return AADSTS392196 (resource not enrolled for mTLS PoP). Use https://graph.microsoft.com for testing.


Verified on

Important

Tested on MSIV2 (CentralUSEUAP, System-Assigned MI, Trusted Launch, Credential Guard active) — VBS KeyGuard-protected RSA-2048 — resource: https://graph.microsoft.com

Call 1 — TokenSource: Network
  cnf.x5t#S256:    URKoHPhqkKqHrMgVv17th-aLTmHaYF2IMaodLpLFnjM
  xms_tbflags:     2
  appidacr:        "2"
  app_displayname: MSIV2
  aud:             https://graph.microsoft.com

Call 2 — TokenSource: Cache ✅

Documentation

Doc Description
📄 msal4j-sdk/docs/mtls-pop.md API reference, cross-SDK comparison table, caching, error table
📄 msal4j-sdk/docs/mtls-pop-architecture.md Deep-dive: JNA→CNG interop, CngSignatureSpi design, 3-level key fallback, AttestationClientLib.dll usage, pure-Java PKCS#10 ASN.1, cert cache, cross-SDK architecture comparison
📄 msal4j-sdk/docs/mtls-pop-manual-testing.md Step-by-step manual testing guide: VM setup, expected output, JWT verification, failure scenarios
🧪 Path1ConfidentialClient.java Path 1 e2e test driver (4 error cases + happy path; --errors-only works with zero setup)
🧪 Path2ManagedIdentity.java Path 2 e2e test driver
🧪 E2ETestRunner.java Fat JAR dispatcher — java -jar mtls-extensions.jar path1|path2 [--errors-only]

Robbie-Microsoft and others added 2 commits April 8, 2026 16:19
Adds mTLS PoP support for both ConfidentialClientApplication (SNI path)
and ManagedIdentityApplication (subprocess path via MsalMtlsMsiHelper.exe).

SNI path (native Java JSSE):
- MtlsPopAuthenticationScheme: TOKEN_TYPE_MTLS_POP constant, computeX5tS256()
  thumbprint computation, buildMtlsTokenEndpoint() with public/sovereign cloud
  handling (US Gov + China unsupported)
- MtlsSslContextHelper: creates SSLSocketFactory from PrivateKey + X509Certificate[]
  via in-memory PKCS12 KeyStore
- TokenRequestExecutor: isMtlsPopRequest(), executeTokenRequestWithMtls(),
  getMtlsClientCertificate(); skips client_assertion, adds token_type=mtls_pop
- ConfidentialClientApplication: validateMtlsPopParameters() pre-flight
  (cert required, tenanted authority, AAD only, region required)

Managed Identity path (subprocess delegation):
- New msal4j-mtls-extensions Maven module bundles MsalMtlsMsiHelper.exe
  (.NET 8 binary using CNG/Schannel; same approach as msal-node since
  Java SunMSCAPI uses legacy CAPI and cannot access KeyGuard keys)
- MtlsMsiClient: subprocess wrapper with concurrent stdout/stderr threads
  to prevent deadlock; supports acquire-token and http-request modes
- MtlsMsiHelperLocator: resolves binary via MSAL_MTLS_HELPER_PATH env var
  or bundled JAR resource (extracted to temp on first use)
- ManagedIdentityApplication: validateMtlsPopParameters() with classpath check
- AcquireTokenByManagedIdentitySupplier: executeMtlsPop() delegates to
  MtlsMsiClient via reflection (avoids compile-time dependency)

Token cache isolation:
- CredentialTypeEnum.ACCESS_TOKEN_WITH_AUTH_SCHEME (AccessToken_With_AuthScheme)
- AccessTokenCacheEntity: keyId field (x5t#S256 thumbprint); getKey() appends
  as 7th segment only when non-blank (Bearer tokens keep 6-segment keys)
- TokenCache.createAccessTokenCacheEntity: sets auth scheme type + keyId
  for mtls_pop token responses

API additions:
- ClientCredentialParameters.withMtlsProofOfPossession(boolean)
- ManagedIdentityParameters.withMtlsProofOfPossession(boolean)
- IAuthenticationResult.tokenType() / bindingCertificate() (default methods)
- AuthenticationResult.tokenType / bindingCertificate fields + builder methods
- TokenResponse: parses token_type from JSON response
- AuthenticationErrorCode.INVALID_REQUEST

Tests: 22 new unit tests in MtlsPopTest covering all new/modified code.
All 324 tests pass (6 pre-existing lab cert errors unchanged).

Docs:
- msal4j-sdk/docs/mtls-pop.md (developer guide)
- msal4j-sdk/docs/mtls-pop-manual-testing.md (testing guide)
- msal4j-sdk/docs/keyguard-jvm-analysis.md (CNG/CAPI/JNI analysis)
- msal4j-mtls-extensions/README.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace subprocess approach with JNA-backed java.security.Provider
  - NCryptLibrary.java, AttestationLibrary.java: JNA interfaces to ncrypt.dll and AttestationClientLib.dll
  - CngKeyGuard.java: CNG key ops (KeyGuard > Hardware fallback), matches msal-go cng_windows.go
  - CngRsaPrivateKey.java: RSAPrivateKey backed by NCRYPT_KEY_HANDLE (non-exportable)
  - CngSignatureSpi.java: SignatureSpi routing NCryptSignHash; delegates to next provider for regular keys
  - CngProvider.java: java.security.Provider registering SHA256withRSA/SHA1withRSA/RSASSA-PSS
  - ImdsV2Client.java: IMDS /getplatformmetadata and /issuecredential HTTP client
  - Pkcs10Builder.java: pure-Java PKCS#10 CSR DER encoding matching msal-go generateCSR()
  - MtlsBindingCertManager.java: orchestrates IMDS flow with in-process cache
  - MtlsMsiClient.java: rewritten to use JNA+JSSE, no subprocess or .NET runtime required
- Fix extractString() in ImdsV2Client to use sequential escape processing
- Fix CngSignatureSpi.engineSetParameter to forward PSSParameterSpec to delegate
- Remove MtlsMsiHelperLocator.java and MsalMtlsMsiHelper.exe (subprocess artifacts)
- Rewrite msal4j-mtls-extensions README.md to match msal-go README style
- Add unit tests (56 passing):
  - ImdsV2ClientTest: extractString() edge cases, PlatformMetadata.cuIdString(), CredentialResponse
  - Pkcs10BuilderTest: DER primitives, full CSR generation (CngKeyGuard.signPss mocked)
  - MtlsMsiClientTest: null resource validation, buildTokenUrl, buildTokenRequestBody
  - MtlsBindingInfoTest: 5-minute early-expiry logic
  - CngProviderTest: provider registration, installIfAbsent() idempotency
  - CngSignatureSpiTest: SHA256withRSA/SHA1withRSA/RSASSA-PSS delegation to SunRsaSign
- Add maven-surefire-plugin 3.1.2 with JVM args for Mockito inline mocking on Java 21

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Robbie-Microsoft and others added 7 commits April 10, 2026 00:22
Bugs fixed during manual path 2 (Managed Identity mTLS PoP) testing:

1. AttestationLibrary.java: DLL requires non-null log function pointer
   - Added LogCallback JNA Callback interface with NOOP_LOG static instance
   - Changed logFunc field type from Pointer to LogCallback
   - Without this fix: InitAttestationLib returns 0xFFFFFFF8 (invalid args)

2. Pkcs10Builder.java: CSR attributes tag must be 0xA0 (constructed)
   - Changed contextImplicit(0, attrSeq) -> contextExplicit(0, attrSeq)
   - PKCS#10 [0] IMPLICIT Attributes uses 0xA0 (context + constructed), not 0x80
   - Without this fix: IMDS returns HTTP 400 'CSR is invalid'

3. CngSignatureSpi.java: null guard and provider interaction fixes
   - engineInitVerify throws InvalidKeyException so chooseProvider() skips
     our SPI for verification (server cert, signature checks); SunRsaSign
     handles those correctly without CNG involvement
   - Added null guards to engineUpdate overloads
   - engineInitSign converts IllegalStateException -> InvalidKeyException

4. pom.xml: Added build-helper-maven-plugin (e2e sources) and
   maven-shade-plugin (fat JAR with signature file stripping)

5. e2e test driver: Path2ManagedIdentity.java added
   - Mirrors msal-go path2_managedidentity/main.go
   - Tests first call (full IMDS flow), second call (cert cache), downstream

End-to-end test results on Azure VM (centraluseuap):
- AttestationClientLib.dll: initialized and attestation token obtained
- IMDS /issuecredential: binding cert issued (HTTP 200)
- mTLS handshake to centraluseuap.mtlsauth.microsoft.com: SUCCESS
- AAD token endpoint: AADSTS392196 (tenant config, same as MSAL.NET)

The AADSTS392196 error is environment-specific (resource not configured for
certificate-bound tokens in this tenant), not a code bug. Behavior matches
MSAL.NET reference implementation exactly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ntless check

- Add Path1ConfidentialClient.java: mirrors msal-go path1_confidential/main.go
  - 4 error cases: missing region, /common, /organizations, secret credential
  - Happy path: acquire mTLS PoP token, print binding cert, cache check, downstream call
  - PEM loading with PKCS#1/PKCS#8 auto-detect + bundled test cert fallback
- Add E2ETestRunner.java: dispatcher; routes path1/path2 args to test drivers
- Update Path2ManagedIdentity.java: add static run() method for dispatcher
- Update pom.xml: mainClass → E2ETestRunner, add e2e resources dir
- Fix AADAuthority.isTenantless: now true for both 'common' and 'organizations'
  (was only 'common') so validateMtlsPopParameters catches /organizations correctly
- Add mtls-test-cert.p12 as bundled e2e resource (no PEM files required for error tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
management.azure.com is not enrolled for mTLS PoP in this tenant;
graph.microsoft.com is. Mirrors msal-go's path2_managedidentity/main.go
which uses https://graph.microsoft.com as the resource.

Verified: full flow succeeds — binding cert received, mTLS PoP token
issued, cert cache working on second call, downstream TLS handshake OK.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add appid, app_displayname, idtyp, appidacr, aud, xms_tbflags claims
to summary output. Print raw JWT after summary for verification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- msal4j-sdk/docs/mtls-pop.md:
  - Path 2: replace subprocess/.NET description with JNA/CNG description
  - Remove 'Why Java Cannot Use CNG' section (no longer true)
  - Remove .NET 8 runtime from requirements
  - Update ManagedIdentityParameters API table description

- msal4j-sdk/docs/mtls-pop-manual-testing.md:
  - Path 2: remove .NET helper exe smoke-test, .NET runtime prereq
  - Replace with fat JAR e2e runner (path2 --attest)
  - Show expected output including JWT claims and downstream 401
  - Change resource from management.azure.com to graph.microsoft.com
  - Add resource enrollment note (AADSTS392196)
  - Update troubleshooting table

- msal4j-mtls-extensions/README.md:
  - Fix broken quick links (docs are in msal4j-sdk/docs/, not here)
  - Change resource examples from management.azure.com to graph.microsoft.com
  - Add resource enrollment note
  - Add Path 1 (Confidential Client) quick start section
  - Add e2e test driver section with path1/path2 commands

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…omparison table

- Fix Path 1 API calls: ClientCredentialFactory.createFromCertificate + withMtlsProofOfPossession() (no-arg)
- Add cross-SDK comparison table (msal-java vs dotnet vs go vs node) to mtls-pop.md
- Update mtls-pop-manual-testing.md: fat JAR e2e runner replaces .NET helper smoke-test
- Add mtls-pop-architecture.md: JNA/CNG design, sequence diagrams, key level fallback, cert cache
- Link architecture doc from mtls-pop.md references and mtls-extensions README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove TRUST_ALL X509TrustManager that accepted any server certificate
- buildSslSocketFactory now throws MtlsMsiException if insecure=true is passed
- Pass null TrustManagers to SSLContext.init() so JVM default trust store is used
- Remove unused TrustManager and X509TrustManager imports

Resolves GitHub Advanced Security CodeQL alert:
'TrustManager that accepts all certificates' (High)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Robbie-Microsoft Robbie-Microsoft marked this pull request as ready for review April 10, 2026 17:01
@Robbie-Microsoft Robbie-Microsoft requested a review from a team as a code owner April 10, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants