diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 37ccef08..a8b8a0d4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,10 +28,12 @@ - Multi-cloud and B2C support ### Repository Structure -This repository contains three Maven modules: +This repository contains four default Maven modules plus one profile-only E2E module: - **`msal4j-sdk/`** - The main MSAL Java library (focus of development) - **`msal4j-brokers/`** - Broker integration for native authentication (Windows WAM) - **`msal4j-persistence-extension/`** - Cross-platform token cache persistence helpers +- **`msal4j-mtls-extensions/`** - Optional Windows KeyGuard/attestation bridge for Managed Identity v2 mTLS PoP; bundles Microsoft.Azure.Security.KeyGuardAttestation 1.1.5 while Java JCA/JSSE performs TLS +- **`msal4j-mtls-extensions-e2e/`** - Manual validation app, included only by the Maven `e2e` profile For most work, focus on **`msal4j-sdk/`**. @@ -151,6 +153,8 @@ MSAL4J supports multiple authentication flows, each with a public `*Parameters` - **Parameters**: `ManagedIdentityParameters` - For Azure resources (VMs, App Service, Functions) - **Internal**: `ManagedIdentityRequest` → `AcquireTokenByManagedIdentitySupplier` - **Key Classes**: `ManagedIdentitySource` implementations (`IMDSManagedIdentitySource`, `AppServiceManagedIdentitySource`, etc.) +- **Optional mTLS PoP**: `withMtlsProofOfPossession()` requests a KeyGuard binding; `withAttestationSupport()` separately requires MAA attestation and fails closed. Core owns OAuth/HTTP/cache behavior; `msal4j-mtls-extensions` owns KeyGuard/CNG/optional attestation and returns a reusable process-local `IMtlsBindingContext` with an `SSLContext` and `X509ExtendedKeyManager`. Custom HTTP clients must implement `IMtlsCapableHttpClient` and consume the request-specific context or socket factory. +- **mTLS capability discovery**: `getManagedIdentityCapabilities()` reports the detected source and maximum `MtlsBindingStrength`; `MtlsPopOptions` lets credential chains require a minimum strength before token acquisition succeeds. ### Common Flows (All Application Types) @@ -236,4 +240,3 @@ Update this file whenever you make changes that affect: By keeping these instructions current, you help ensure that future Copilot agents (and developers) can quickly understand and work with the MSAL Java library effectively. --- - diff --git a/README.md b/README.md index 11bdd2eb..b64b178d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,17 @@ MSAL4J supports multiple [application types and authentication scenarios](https: Refer the [Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-java/wiki) pages for more details on the usage of MSAL Java and the supported scenarios. +### Managed Identity v2 attested mTLS PoP + +The optional `msal4j-mtls-extensions` module provides a Windows KeyGuard-backed, +attested Managed Identity v2 flow while preserving Java 8 compatibility. MSAL +returns an `IMtlsBindingContext` whose standard JSSE `SSLContext` can be used by +the application for independent downstream mTLS calls. + +See [the extension guide](msal4j-mtls-extensions/README.md). Reviewers can use +the [detailed review guide](msal4j-sdk/docs/managed-identity-v2-mtls-pop-review-guide.md) +for architecture diagrams, security checklists, file order, and manual validation. + ## Migrating from ADAL If your application is using ADAL for Java (ADAL4J), we recommend you to update to use MSAL4J. No new feature work will be done in ADAL4J. diff --git a/changelog.txt b/changelog.txt index db9a0ba4..3d376c91 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,17 @@ +Unreleased +========== +- Add Managed Identity v2 KeyGuard mTLS PoP support. + - Custom `IHttpClient` implementations must explicitly implement + `IMtlsCapableHttpClient`, consume the request-specific `SSLContext` or + `SSLSocketFactory`, and disable redirects for credential-bound requests. + - Existing custom HTTP clients fail closed until they opt into this contract. + - The initial native KeyGuard attestation package supports Windows x64 only. + - Credential chains can discover IMDS v2/KeyGuard capability and require a + minimum `MtlsBindingStrength` before token acquisition succeeds. + - Results expose the actual binding strength with the reusable context. + - The native attestation DLL is verified with SHA-256 and Windows + Authenticode before it is loaded. + Version 1.25.1 ============= - Add claimsFromClient API for client-originated claims on confidential-client flows (#1039) diff --git a/msal4j-mtls-extensions-e2e/.gitignore b/msal4j-mtls-extensions-e2e/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/msal4j-mtls-extensions-e2e/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/msal4j-mtls-extensions-e2e/pom.xml b/msal4j-mtls-extensions-e2e/pom.xml new file mode 100644 index 00000000..432b39cf --- /dev/null +++ b/msal4j-mtls-extensions-e2e/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + com.microsoft.azure + msal4j-mtls-extensions-e2e + 1.0.0 + jar + + + 8 + 8 + UTF-8 + + + + + com.microsoft.azure + msal4j + 1.25.1 + + + com.microsoft.azure + msal4j-mtls-extensions + 1.0.0 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + false + true + e2e + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + META-INF/*.EC + + + + + + com.microsoft.aad.msal4j.mtls.e2e.ManagedIdentityMtlsPopKeyVaultDevApp + + + + + + + + + diff --git a/msal4j-mtls-extensions-e2e/src/main/java/com/microsoft/aad/msal4j/mtls/e2e/ManagedIdentityMtlsPopKeyVaultDevApp.java b/msal4j-mtls-extensions-e2e/src/main/java/com/microsoft/aad/msal4j/mtls/e2e/ManagedIdentityMtlsPopKeyVaultDevApp.java new file mode 100644 index 00000000..33bec9b8 --- /dev/null +++ b/msal4j-mtls-extensions-e2e/src/main/java/com/microsoft/aad/msal4j/mtls/e2e/ManagedIdentityMtlsPopKeyVaultDevApp.java @@ -0,0 +1,362 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls.e2e; + +import com.microsoft.aad.msal4j.IAuthenticationResult; +import com.microsoft.aad.msal4j.IMtlsBindingContext; +import com.microsoft.aad.msal4j.ManagedIdentityApplication; +import com.microsoft.aad.msal4j.ManagedIdentityId; +import com.microsoft.aad.msal4j.ManagedIdentityParameters; +import com.microsoft.aad.msal4j.TokenSource; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.UUID; + +/** + * Manual Java 8 validation for attested managed identity v2 mTLS PoP with Key Vault. + */ +public final class ManagedIdentityMtlsPopKeyVaultDevApp { + + private static final String RESOURCE = "https://vault.azure.net"; + + public static void main(String[] args) throws Exception { + boolean tokenOnly = Boolean.parseBoolean( + System.getenv("MSAL_JAVA_MTLS_TOKEN_ONLY")); + String vaultUrl = tokenOnly + ? null : required("MSAL_JAVA_MTLS_AKV_URL"); + String secretName = tokenOnly + ? null : required("MSAL_JAVA_MTLS_AKV_SECRET_NAME"); + String identityClientId = System.getenv("MSAL_JAVA_MTLS_IDENTITY_CLIENT_ID"); + String mismatchIdentityClientId = + System.getenv("MSAL_JAVA_MTLS_MISMATCH_IDENTITY_CLIENT_ID"); + String expectedSecretValue = + System.getenv("MSAL_JAVA_MTLS_EXPECTED_SECRET_VALUE"); + boolean forceRefresh = Boolean.parseBoolean( + System.getenv("MSAL_JAVA_MTLS_FORCE_REFRESH")); + String runId = UUID.randomUUID().toString(); + + ManagedIdentityId identity = isBlank(identityClientId) + ? ManagedIdentityId.systemAssigned() + : ManagedIdentityId.userAssignedClientId(identityClientId); + ManagedIdentityApplication application = + ManagedIdentityApplication.builder(identity).build(); + + System.out.println("Java Managed Identity v2 mTLS PoP manual validation"); + System.out.println(); + System.out.println("Platform: " + System.getProperty("os.name")); + System.out.println("JVM: " + System.getProperty("java.version")); + System.out.println("Identity: " + (isBlank(identityClientId) + ? "SystemAssigned" : "UserAssigned")); + System.out.println("Attestation: enabled"); + System.out.println("Resource: " + RESOURCE); + if (!tokenOnly) { + System.out.println("AKV host: " + new URL(vaultUrl).getHost()); + } + System.out.println("Correlation ID: " + runId); + + System.out.println("\n[1] Acquiring attested mTLS PoP token..."); + IAuthenticationResult first = acquire(application, false); + verifyResult(first); + System.out.println("PASS: token_type = mtls_pop"); + System.out.println("PASS: binding certificate returned"); + System.out.println("PASS: reusable JSSE binding context returned"); + + System.out.println("\n[2] Verifying certificate-bound token..."); + verifyTokenBinding(first); + System.out.println("PASS: cnf.x5t#S256 matches binding certificate"); + System.out.println("Binding key ID: " + mask(first.mtlsBindingContext().keyId())); + + if (tokenOnly) { + System.out.println("\nRESULT: PASS - attested mTLS PoP token acquired"); + return; + } + + System.out.println("\n[3] Building independent Java 8 HTTPS client..."); + System.out.println("PASS: HttpsURLConnection configured from returned SSLContext"); + + System.out.println("\n[4] Calling AKV..."); + String response = callKeyVault(first, vaultUrl, secretName); + if (!isBlank(expectedSecretValue)) { + String actualValue = extractJsonString(response, "value"); + if (!expectedSecretValue.equals(actualValue)) { + throw new IllegalStateException( + "AKV secret value did not match the expected value."); + } + } + System.out.println("PASS: HTTP 200"); + System.out.println("PASS: AKV response validated"); + + System.out.println("\n[5] Verifying the token is rejected without its certificate..."); + verifyMissingBindingRejected(first, vaultUrl, secretName); + + int nextStep = 6; + if (!isBlank(mismatchIdentityClientId)) { + System.out.println("\n[" + nextStep++ + "] Verifying mismatched binding is rejected..."); + ManagedIdentityApplication mismatchApplication = + ManagedIdentityApplication.builder( + ManagedIdentityId.userAssignedClientId( + mismatchIdentityClientId)) + .build(); + IAuthenticationResult mismatchBinding = + acquire(mismatchApplication, false); + verifyResult(mismatchBinding); + if (first.mtlsBindingContext().keyId().equals( + mismatchBinding.mtlsBindingContext().keyId())) { + throw new IllegalStateException( + "Negative binding acquisition returned the same certificate."); + } + KeyVaultResponse rejection = callKeyVault( + first, + mismatchBinding.mtlsBindingContext(), + vaultUrl, + secretName, + false); + if (rejection.status == 200) { + throw new IllegalStateException( + "AKV accepted token A with mismatched binding B."); + } + System.out.println("PASS: token A + binding B rejected with HTTP " + + rejection.status); + } + + System.out.println("\n[" + nextStep++ + "] Reacquiring..."); + IAuthenticationResult cached = acquire(application, false); + verifyResult(cached); + if (cached.metadata().tokenSource() != TokenSource.CACHE) { + throw new IllegalStateException("Second acquisition was not a cache hit."); + } + if (!first.mtlsBindingContext().keyId() + .equals(cached.mtlsBindingContext().keyId())) { + throw new IllegalStateException( + "Cache hit returned a different binding generation."); + } + System.out.println("PASS: TokenSource = CACHE"); + System.out.println("PASS: matching binding context available"); + + if (forceRefresh) { + System.out.println("\n[" + nextStep + "] Force refresh..."); + IAuthenticationResult refreshed = acquire(application, true); + verifyResult(refreshed); + if (refreshed.metadata().tokenSource() != TokenSource.IDENTITY_PROVIDER) { + throw new IllegalStateException( + "Force refresh did not use the identity provider."); + } + callKeyVault(refreshed, vaultUrl, secretName); + System.out.println("PASS: TokenSource = IDENTITY_PROVIDER"); + System.out.println("PASS: force-refreshed binding context returned HTTP 200"); + } + + System.out.println("\nRESULT: PASS"); + } + + private static IAuthenticationResult acquire( + ManagedIdentityApplication application, + boolean forceRefresh) throws Exception { + ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder(RESOURCE) + .withMtlsProofOfPossession() + .withAttestationSupport() + .forceRefresh(forceRefresh) + .build(); + return application.acquireTokenForManagedIdentity(parameters).get(); + } + + private static void verifyResult(IAuthenticationResult result) { + if (result == null + || isBlank(result.accessToken()) + || !"mtls_pop".equals(result.tokenType()) + || result.bindingCertificate() == null + || result.mtlsBindingContext() == null + || result.mtlsBindingContext().sslContext() == null + || isBlank(result.mtlsBindingContext().keyId())) { + throw new IllegalStateException( + "Managed identity mTLS PoP result was incomplete."); + } + } + + private static void verifyTokenBinding(IAuthenticationResult result) + throws Exception { + IMtlsBindingContext context = result.mtlsBindingContext(); + String payload = result.accessToken().split("\\.")[1]; + String payloadJson = new String( + Base64.getUrlDecoder().decode(padBase64(payload)), + StandardCharsets.UTF_8); + String cnfObject = extractJsonObject(payloadJson, "cnf"); + String tokenKeyId = extractJsonString(cnfObject, "x5t#S256"); + String certificateKeyId = Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256") + .digest(result.bindingCertificate().getEncoded())); + if (!context.keyId().equals(tokenKeyId) + || !context.keyId().equals(certificateKeyId)) { + throw new IllegalStateException( + "Token cnf, binding context and certificate key IDs differ."); + } + } + + private static String callKeyVault( + IAuthenticationResult result, + String vaultUrl, + String secretName) throws Exception { + KeyVaultResponse response = callKeyVault( + result, + result.mtlsBindingContext(), + vaultUrl, + secretName, + true); + return response.body; + } + + private static KeyVaultResponse callKeyVault( + IAuthenticationResult token, + IMtlsBindingContext binding, + String vaultUrl, + String secretName, + boolean requireSuccess) throws Exception { + String endpoint = trimTrailingSlash(vaultUrl) + + "/secrets/" + secretName + "?api-version=7.5"; + HttpsURLConnection connection = + (HttpsURLConnection) new URL(endpoint).openConnection(); + SSLContext sslContext = binding == null + ? createTls12ContextWithoutClientCertificate() + : binding.sslContext(); + connection.setSSLSocketFactory(sslContext.getSocketFactory()); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(30_000); + connection.setReadTimeout(30_000); + connection.setRequestMethod("GET"); + connection.setRequestProperty( + "Authorization", + "mtls_pop " + token.accessToken()); + connection.setRequestProperty("x-ms-tokenboundauth", "true"); + connection.setRequestProperty( + "x-ms-client-request-id", + UUID.randomUUID().toString()); + + int status = connection.getResponseCode(); + InputStream stream = status == 200 + ? connection.getInputStream() : connection.getErrorStream(); + String body = readBody(stream); + if (requireSuccess && status != 200) { + throw new IllegalStateException( + "AKV token-bound request failed with HTTP " + status + "."); + } + return new KeyVaultResponse(status, body); + } + + private static void verifyMissingBindingRejected( + IAuthenticationResult token, + String vaultUrl, + String secretName) throws Exception { + KeyVaultResponse response = callKeyVault( + token, + null, + vaultUrl, + secretName, + false); + String errorCode = extractJsonString(response.body, "code"); + if (response.status != 401 || !"Unauthorized".equals(errorCode)) { + throw new IllegalStateException( + "AKV returned an unexpected response for an mtls_pop token " + + "without its binding certificate: HTTP " + + response.status + ", code=" + errorCode); + } + System.out.println( + "PASS: token without certificate rejected with HTTP 401 Unauthorized"); + } + + private static SSLContext createTls12ContextWithoutClientCertificate() + throws Exception { + SSLContext context = SSLContext.getInstance("TLSv1.2"); + context.init(null, null, null); + return context; + } + + private static String readBody(InputStream stream) throws Exception { + if (stream == null) { + return ""; + } + StringBuilder body = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + body.append(line); + } + } + return body.toString(); + } + + private static String extractJsonObject(String json, String key) { + int keyIndex = json.indexOf("\"" + key + "\""); + int start = keyIndex < 0 ? -1 : json.indexOf('{', keyIndex); + int end = start < 0 ? -1 : json.indexOf('}', start); + if (start < 0 || end < 0) { + return null; + } + return json.substring(start, end + 1); + } + + private static String extractJsonString(String json, String key) { + if (json == null) { + return null; + } + int keyIndex = json.indexOf("\"" + key + "\""); + int colon = keyIndex < 0 ? -1 : json.indexOf(':', keyIndex); + int start = colon < 0 ? -1 : json.indexOf('"', colon); + int end = start < 0 ? -1 : json.indexOf('"', start + 1); + return start < 0 || end < 0 ? null : json.substring(start + 1, end); + } + + private static String required(String name) { + String value = System.getenv(name); + if (isBlank(value)) { + throw new IllegalArgumentException( + "Required environment variable is missing: " + name); + } + return value; + } + + private static String trimTrailingSlash(String value) { + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value; + } + + private static String padBase64(String value) { + int remainder = value.length() % 4; + return remainder == 0 ? value : value + (remainder == 2 ? "==" : "="); + } + + private static String mask(String value) { + return value.length() <= 8 + ? "" + : value.substring(0, 4) + "..." + value.substring(value.length() - 4); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private static final class KeyVaultResponse { + final int status; + final String body; + + KeyVaultResponse(int status, String body) { + this.status = status; + this.body = body; + } + } + + private ManagedIdentityMtlsPopKeyVaultDevApp() { + } +} diff --git a/msal4j-mtls-extensions/.gitignore b/msal4j-mtls-extensions/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/msal4j-mtls-extensions/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/msal4j-mtls-extensions/README.md b/msal4j-mtls-extensions/README.md new file mode 100644 index 00000000..7db25982 --- /dev/null +++ b/msal4j-mtls-extensions/README.md @@ -0,0 +1,165 @@ +# MSAL4J Managed Identity v2 mTLS extension + +`msal4j-mtls-extensions` adds Managed Identity v2 mTLS +Proof-of-Possession (PoP) to `ManagedIdentityApplication`. + +The extension keeps native interop limited to Windows KeyGuard/CNG signing and +Microsoft Azure Attestation. OAuth, HTTP policy, token caching, TLS, and +downstream resource calls remain Java-owned: + +```text +ManagedIdentityApplication + -> IMDS v2 platform metadata + -> KeyGuard non-exportable RSA key + -> MAA attestation + -> IMDS v2 binding certificate + -> JSSE mTLS token request + -> IAuthenticationResult + reusable IMtlsBindingContext +``` + +## Requirements + +- Java 8 or later +- Windows x64 Trusted Launch Azure VM or VMSS instance +- Secure Boot, vTPM, VBS KeyGuard, and Managed Identity enabled +- The extension bundles the Microsoft-signed x64 `AttestationClientLib.dll` from + `Microsoft.Azure.Security.KeyGuardAttestation` 1.1.5, matching MSAL.NET +- A resource and tenant enrolled for Managed Identity mTLS PoP + +Attestation is optional at the public API. When +`withAttestationSupport()` is enabled, bundled DLL extraction/loading, KeyGuard +attestation, or invalid attestation evidence fails closed. Platform support, +credential issuance, certificate validation, and an explicit +`token_type=mtls_pop` response are always required. + +The bundled native library is verified with both a pinned SHA-256 digest and +Windows `WinVerifyTrust` Authenticode validation before loading. Its extraction +directory is restricted to the current Windows user. + +## Token acquisition + +```java +ManagedIdentityApplication application = ManagedIdentityApplication + .builder(ManagedIdentityId.systemAssigned()) + .build(); + +ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession( + MtlsPopOptions.builder() + .minimumBindingStrength( + MtlsBindingStrength.KEY_GUARD) + .build()) + .withAttestationSupport() + .build(); + +IAuthenticationResult result = application + .acquireTokenForManagedIdentity(parameters) + .get(); +``` + +Before acquisition, credential chains can call +`application.getManagedIdentityCapabilities()` and inspect +`maxSupportedBindingStrength()` and `isMtlsPopSupportedByHost()`. Discovery verifies +the IMDS v2 and KeyGuard capability without acquiring an access token. + +For a user-assigned identity: + +```java +ManagedIdentityId identity = + ManagedIdentityId.userAssignedClientId(""); +``` + +The result exposes: + +- `tokenType()` - must be `mtls_pop` +- `bindingCertificate()` - the IMDS-issued leaf certificate +- `mtlsBindingContext()` - the live, process-local binding capability +- `mtlsBindingStrength()` - the actual binding strength used by the result +- `mtlsBindingContext().sslContext()` - a reusable Java JSSE `SSLContext` +- `mtlsBindingContext().keyManager()` - the non-exportable-key + `X509ExtendedKeyManager` for custom TLS contexts +- `mtlsBindingContext().keyId()` - Base64URL SHA-256 of the complete leaf DER + +Private key bytes and native handles are never exposed. Binding contexts are +transient and are not serialized into persistent token caches. + +## Independent Java 8 Key Vault call + +```java +URL url = new URL( + "https://.vault.azure.net/secrets/?api-version=7.5"); +HttpsURLConnection connection = + (HttpsURLConnection) url.openConnection(); + +connection.setSSLSocketFactory( + result.mtlsBindingContext().sslContext().getSocketFactory()); +connection.setInstanceFollowRedirects(false); +connection.setRequestMethod("GET"); +connection.setRequestProperty( + "Authorization", + result.tokenType() + " " + result.accessToken()); +connection.setRequestProperty("x-ms-tokenboundauth", "true"); + +if (connection.getResponseCode() != 200) { + throw new IllegalStateException("Token-bound Key Vault call failed."); +} +``` + +The application owns this downstream call. The extension does not expose a +native HTTP helper or a downstream request API. + +## Caching and rotation + +- Bearer and mTLS PoP access tokens use distinct cache partitions. +- The mTLS partition includes the complete-certificate key ID. +- Attested and unattested requests use distinct cache partitions. +- A renewed certificate creates a new token-cache partition even when it uses + the same underlying RSA key. +- Cache hits reacquire the matching live binding context before returning. +- Certificates enter rotation 24 hours before expiry. +- Attestation JWTs are cached by normalized attestation endpoint and key ID, + with a five-minute freshness buffer and per-key single-flight behavior. + +Custom application HTTP clients must implement `IMtlsCapableHttpClient`, honor +the request-specific `SSLContext` or `SSLSocketFactory`, and disable redirects +for mTLS token requests. The supplied `SSLContext` uses JVM default trust +managers. Applications that need custom trust anchors can build a context from +the exposed key manager. + +The current binding context uses TLS 1.2. TLS 1.3 support is being investigated +with the service team because the service does not yet request the required +client certificate during TLS 1.3 negotiation. + +The initial native package is Windows x64 only. Windows ARM64 callers receive a +typed unsupported-architecture failure before native loading. + +## Manual validation + +From the repository root: + +```powershell +.\run-java-msi-v2-mtls-devapp.ps1 +``` + +To validate only token acquisition and certificate binding without calling a +downstream resource: + +```powershell +$env:MSAL_JAVA_MTLS_TOKEN_ONLY = "true" +.\run-java-msi-v2-mtls-devapp.ps1 +``` + +For the negative certificate-binding proof, attach a distinct user-assigned +managed identity to the VM and set: + +```powershell +$env:MSAL_JAVA_MTLS_MISMATCH_IDENTITY_CLIENT_ID = "" +``` + +The app first proves token A with binding A returns HTTP 200, then requires +token A with binding B to be rejected. + +See +[`msal4j-sdk/docs/managed-identity-v2-mtls-pop.md`](../msal4j-sdk/docs/managed-identity-v2-mtls-pop.md) +for architecture, protocol reconciliation, and troubleshooting. diff --git a/msal4j-mtls-extensions/pom.xml b/msal4j-mtls-extensions/pom.xml new file mode 100644 index 00000000..1a2389a8 --- /dev/null +++ b/msal4j-mtls-extensions/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + com.microsoft.azure + msal4j-mtls-extensions + 1.0.0 + jar + + Microsoft Authentication Library for Java - mTLS Extensions + + Extension package that enables mTLS Proof-of-Possession (mTLS PoP) token acquisition + for Azure Managed Identity scenarios requiring KeyGuard-bound certificates. Uses JNA + to call Windows CNG (ncrypt.dll) and AttestationClientLib.dll directly from Java, + implementing a java.security.Provider that allows JSSE to use a non-exportable + KeyGuard RSA key during the TLS handshake. No .NET runtime or subprocess required. + + + + 8 + 8 + UTF-8 + 1.1.5 + 90dfcce20e1a74519b49796eeee17e6e59a257c3acf754f454a49380d28a568b + + + + + com.microsoft.azure + msal4j + 1.25.1 + + + + + net.java.dev.jna + jna + 5.14.0 + + + + + org.junit.jupiter + junit-jupiter-api + 5.10.0 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.0 + test + + + org.mockito + mockito-inline + 4.11.0 + test + + + org.mockito + mockito-junit-jupiter + 4.11.0 + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + ${keyguard.attestation.version} + ${keyguard.attestation.sha256} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + + + + diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationLibrary.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationLibrary.java new file mode 100644 index 00000000..19ee93e6 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationLibrary.java @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Callback; +import com.sun.jna.Library; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; +import com.sun.jna.ptr.PointerByReference; + +import java.util.Arrays; +import java.util.List; + +/** + * JNA binding for {@code AttestationClientLib.dll} — the Windows DLL shipped by Azure + * that produces a MAA (Microsoft Azure Attestation) JWT proving a CNG KeyGuard key is + * hardware-protected. + * + *

Function signatures (ANSI cdecl, x64 Windows) documented in MSAL.NET's + * {@code KeyGuardMaa/AttestationInterop.cs} and also used by msal-go's + * {@code cng_windows.go}:

+ *
+ *   int  InitAttestationLib(AttestationLogInfo*)
+ *   int  AttestKeyGuardImportKey(char* endpoint, char* authToken, char* clientPayload,
+ *                                NCRYPT_KEY_HANDLE keyHandle, char** token, char* clientId)
+ *   void FreeAttestationToken(char* token)
+ *   void UninitAttestationLib()
+ * 
+ * + *

This interface is loaded lazily via {@link AttestationLibraryLoader} from the + * Microsoft-signed DLL bundled in the optional extension. It is only extracted and loaded + * when MAA attestation is requested.

+ */ +interface AttestationLibrary extends Library { + + /** + * No-op log callback that satisfies the DLL's requirement for a non-null LogFunc. + * + *

The DLL requires a non-null log function pointer in {@link AttestationLogInfo}. + * Passing {@code Pointer.NULL} causes {@code InitAttestationLib} to return an error + * (0xFFFFFFF8 = -8). Mirrors msal-go's {@code dummyLogCallback}.

+ * + *

Signature (cdecl, x64 Windows): + * {@code void LogFunc(void* ctx, char* tag, int lvl, char* func, int line, char* msg)}

+ */ + interface LogCallback extends Callback { + void log(Pointer ctx, Pointer tag, int level, Pointer func, int line, Pointer msg); + } + + /** Shared no-op log callback instance — kept alive to prevent GC. */ + LogCallback NOOP_LOG = (ctx, tag, level, func, line, msg) -> {}; + + /** + * Mirrors the {@code AttestationLogInfo} struct: + *
struct AttestationLogInfo { LogFunc Log; void* Ctx; }
+ * + *

The {@code logFunc} field MUST be a non-null function pointer — the DLL validates + * this and returns an error if it is null. Use {@link #NOOP_LOG} for no-op logging.

+ */ + class AttestationLogInfo extends Structure { + /** Function pointer for the log callback. MUST NOT be null. */ + public LogCallback logFunc; + /** Caller context pointer, passed as first arg to logFunc. */ + public Pointer ctx; + + public AttestationLogInfo() { + logFunc = NOOP_LOG; // DLL requires a non-null log function pointer + ctx = Pointer.NULL; + } + + @Override + protected List getFieldOrder() { + return Arrays.asList("logFunc", "ctx"); + } + } + + /** + * Initializes the attestation library. + * + * @param logInfo logging configuration; {@code logFunc} MUST be non-null + * @return 0 on success, non-zero on failure + */ + int InitAttestationLib(AttestationLogInfo logInfo); + + /** + * Produces a MAA JWT proving the given CNG key is VBS/KeyGuard-protected. + * + * @param endpoint MAA endpoint URL (ANSI string, e.g. "https://sharedcuse.cuse.attest.azure.net") + * @param authToken unused, pass null + * @param clientPayload unused, pass null + * @param keyHandle the {@code NCRYPT_KEY_HANDLE} from NCrypt* operations + * @param tokenOut receives the pointer to the MAA JWT string (caller must free with FreeAttestationToken) + * @param clientId managed identity client ID (ANSI string) + * @return 0 on success, non-zero on failure + */ + int AttestKeyGuardImportKey(String endpoint, String authToken, String clientPayload, + Pointer keyHandle, PointerByReference tokenOut, String clientId); + + /** + * Frees a MAA JWT string allocated by {@link #AttestKeyGuardImportKey}. + * + * @param token the pointer returned in {@code tokenOut} by AttestKeyGuardImportKey + */ + void FreeAttestationToken(Pointer token); + + /** Uninitializes the attestation library. Call after all attestation operations. */ + void UninitAttestationLib(); +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationLibraryLoader.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationLibraryLoader.java new file mode 100644 index 00000000..d8729648 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationLibraryLoader.java @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Native; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclEntry; +import java.nio.file.attribute.AclEntryPermission; +import java.nio.file.attribute.AclEntryType; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.UserPrincipal; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.EnumSet; + +final class AttestationLibraryLoader { + + static final String VERSION = "1.1.5"; + static final String RESOURCE_PATH = + "/META-INF/native/win-x64/AttestationClientLib.dll"; + static final String SHA256 = + "90dfcce20e1a74519b49796eeee17e6e59a257c3acf754f454a49380d28a568b"; + + private static final Object LOAD_LOCK = new Object(); + private static volatile AttestationLibrary loadedLibrary; + + private AttestationLibraryLoader() { + } + + static AttestationLibrary load() throws MtlsMsiException { + AttestationLibrary library = loadedLibrary; + if (library != null) { + return library; + } + + synchronized (LOAD_LOCK) { + library = loadedLibrary; + if (library != null) { + return library; + } + + Path extractedLibrary = extractBundledLibrary(); + try { + library = Native.load( + extractedLibrary.toAbsolutePath().toString(), + AttestationLibrary.class); + } catch (UnsatisfiedLinkError e) { + throw new MtlsMsiException( + "Could not load bundled Microsoft.Azure.Security.KeyGuardAttestation " + + VERSION + " native library: " + e.getMessage(), + e); + } + loadedLibrary = library; + return library; + } + } + + static Path extractBundledLibrary() throws MtlsMsiException { + String architecture = System.getProperty("os.arch", ""); + if (!"amd64".equalsIgnoreCase(architecture) + && !"x86_64".equalsIgnoreCase(architecture)) { + throw new MtlsMsiException( + "Microsoft.Azure.Security.KeyGuardAttestation " + VERSION + + " is bundled only for Windows x64; detected architecture: " + + architecture); + } + + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new MtlsMsiException("SHA-256 is unavailable in this Java runtime.", e); + } + + Path directory = null; + Path library = null; + try (InputStream resource = + AttestationLibraryLoader.class.getResourceAsStream(RESOURCE_PATH)) { + if (resource == null) { + throw new MtlsMsiException( + "Bundled Microsoft.Azure.Security.KeyGuardAttestation " + VERSION + + " native library is missing from the extension JAR."); + } + + directory = Files.createTempDirectory("msal4j-keyguard-attestation-"); + restrictToCurrentUser(directory); + library = directory.resolve("AttestationClientLib.dll"); + try (DigestInputStream verifiedResource = + new DigestInputStream(resource, digest)) { + Files.copy(verifiedResource, library); + } + + String actualHash = toHex(digest.digest()); + if (!SHA256.equals(actualHash)) { + Files.deleteIfExists(library); + Files.deleteIfExists(directory); + throw new MtlsMsiException( + "Bundled Microsoft.Azure.Security.KeyGuardAttestation " + VERSION + + " failed SHA-256 verification."); + } + + AuthenticodeVerifier.verify(library); + + File directoryFile = directory.toFile(); + File libraryFile = library.toFile(); + directoryFile.deleteOnExit(); + libraryFile.deleteOnExit(); + return library; + } catch (IOException e) { + if (library != null) { + try { + Files.deleteIfExists(library); + } catch (IOException ignored) { + // Preserve the original extraction failure. + } + } + if (directory != null) { + try { + Files.deleteIfExists(directory); + } catch (IOException ignored) { + // Preserve the original extraction failure. + } + } + throw new MtlsMsiException( + "Could not extract bundled Microsoft.Azure.Security.KeyGuardAttestation " + + VERSION + " native library.", + e); + } + } + + private static void restrictToCurrentUser(Path directory) throws IOException { + AclFileAttributeView aclView = Files.getFileAttributeView( + directory, + AclFileAttributeView.class); + if (aclView == null) { + throw new IOException( + "The temporary directory does not support Windows ACLs."); + } + + UserPrincipal owner = Files.getOwner(directory); + AclEntry ownerAccess = AclEntry.newBuilder() + .setType(AclEntryType.ALLOW) + .setPrincipal(owner) + .setPermissions(EnumSet.allOf(AclEntryPermission.class)) + .build(); + aclView.setAcl(Collections.singletonList(ownerAccess)); + } + + private static String toHex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(String.format("%02x", value & 0xff)); + } + return result.toString(); + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationTokenCache.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationTokenCache.java new file mode 100644 index 00000000..2305cf7b --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AttestationTokenCache.java @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +final class AttestationTokenCache { + + private static final long FRESHNESS_BUFFER_SECONDS = 5L * 60L; + private final Map entries = new ConcurrentHashMap<>(); + private final Map locks = new ConcurrentHashMap<>(); + + String getOrAttest( + String endpoint, + String keyId, + AttestationOperation operation) { + String cacheKey = normalizeEndpoint(endpoint) + "|" + keyId; + Entry cached = entries.get(cacheKey); + if (isFresh(cached)) { + return cached.jwt; + } + + Object lock = locks.computeIfAbsent(cacheKey, ignored -> new Object()); + synchronized (lock) { + cached = entries.get(cacheKey); + if (isFresh(cached)) { + return cached.jwt; + } + String jwt = operation.attest(); + if (jwt == null || jwt.trim().isEmpty()) { + throw new MtlsMsiException( + "KeyGuard attestation failed; no attestation token was produced."); + } + long expiresOn = readExpiry(jwt); + if (expiresOn <= currentEpochSeconds() + FRESHNESS_BUFFER_SECONDS) { + throw new MtlsMsiException( + "KeyGuard attestation returned an expired or insufficiently fresh token."); + } + entries.put(cacheKey, new Entry(jwt, expiresOn)); + return jwt; + } + } + + private static boolean isFresh(Entry entry) { + return entry != null + && entry.expiresOn > currentEpochSeconds() + FRESHNESS_BUFFER_SECONDS; + } + + private static long readExpiry(String jwt) throws MtlsMsiException { + try { + String[] segments = jwt.split("\\."); + if (segments.length < 2) { + throw new IllegalArgumentException("JWT has fewer than two segments"); + } + String payload = new String( + Base64.getUrlDecoder().decode(padBase64(segments[1])), + StandardCharsets.UTF_8); + String marker = "\"exp\""; + int index = payload.indexOf(marker); + int colon = index < 0 ? -1 : payload.indexOf(':', index + marker.length()); + if (colon < 0) { + throw new IllegalArgumentException("JWT has no exp claim"); + } + int start = colon + 1; + while (start < payload.length() + && Character.isWhitespace(payload.charAt(start))) { + start++; + } + int end = start; + while (end < payload.length() && Character.isDigit(payload.charAt(end))) { + end++; + } + return Long.parseLong(payload.substring(start, end)); + } catch (Exception e) { + throw new MtlsMsiException( + "Unable to determine KeyGuard attestation token expiry.", e); + } + } + + private static String padBase64(String value) { + int remainder = value.length() % 4; + if (remainder == 0) { + return value; + } + return value + (remainder == 2 ? "==" : "="); + } + + private static String normalizeEndpoint(String endpoint) { + String normalized = endpoint.trim().toLowerCase(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static long currentEpochSeconds() { + return System.currentTimeMillis() / 1000L; + } + + interface AttestationOperation { + String attest(); + } + + private static final class Entry { + final String jwt; + final long expiresOn; + + Entry(String jwt, long expiresOn) { + this.jwt = jwt; + this.expiresOn = expiresOn; + } + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AuthenticodeVerifier.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AuthenticodeVerifier.java new file mode 100644 index 00000000..5ace5bae --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/AuthenticodeVerifier.java @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; +import com.sun.jna.WString; +import com.sun.jna.win32.W32APIOptions; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +final class AuthenticodeVerifier { + + private static final int ERROR_SUCCESS = 0; + private static final int WTD_UI_NONE = 2; + private static final int WTD_REVOKE_NONE = 0; + private static final int WTD_CHOICE_FILE = 1; + private static final int WTD_STATEACTION_IGNORE = 0; + + private AuthenticodeVerifier() { + } + + static void verify(Path file) { + verify(file, WinTrustHolder.INSTANCE); + } + + static void verify(Path file, WinTrustLibrary winTrust) { + WinTrustFileInfo fileInfo = new WinTrustFileInfo(file); + fileInfo.write(); + WinTrustData trustData = new WinTrustData(fileInfo); + trustData.write(); + + int result = winTrust.WinVerifyTrust( + Pointer.NULL, + new Guid( + 0x00aac56b, + (short) 0xcd44, + (short) 0x11d0, + new byte[]{ + (byte) 0x8c, (byte) 0xc2, 0x00, (byte) 0xc0, + 0x4f, (byte) 0xc2, (byte) 0x95, (byte) 0xee + }), + trustData); + if (result != ERROR_SUCCESS) { + throw new MtlsMsiException(String.format( + "The bundled attestation library failed Windows Authenticode " + + "verification (WinVerifyTrust=0x%08x).", + result)); + } + } + + interface WinTrustLibrary extends Library { + int WinVerifyTrust( + Pointer windowHandle, + Guid actionId, + WinTrustData trustData); + } + + private static final class WinTrustHolder { + private static final WinTrustLibrary INSTANCE = + Native.load( + "wintrust", + WinTrustLibrary.class, + W32APIOptions.UNICODE_OPTIONS); + } + + public static final class Guid extends Structure { + public int data1; + public short data2; + public short data3; + public byte[] data4 = new byte[8]; + + public Guid() { + } + + Guid(int data1, short data2, short data3, byte[] data4) { + this.data1 = data1; + this.data2 = data2; + this.data3 = data3; + this.data4 = data4.clone(); + } + + @Override + protected List getFieldOrder() { + return Arrays.asList("data1", "data2", "data3", "data4"); + } + } + + public static final class WinTrustFileInfo extends Structure { + public int cbStruct; + public WString filePath; + public Pointer fileHandle; + public Pointer knownSubject; + + public WinTrustFileInfo() { + } + + WinTrustFileInfo(Path path) { + cbStruct = size(); + filePath = new WString(path.toAbsolutePath().toString()); + fileHandle = Pointer.NULL; + knownSubject = Pointer.NULL; + } + + @Override + protected List getFieldOrder() { + return Arrays.asList( + "cbStruct", + "filePath", + "fileHandle", + "knownSubject"); + } + } + + public static final class WinTrustData extends Structure { + public int cbStruct; + public Pointer policyCallbackData; + public Pointer sipClientData; + public int uiChoice; + public int revocationChecks; + public int unionChoice; + public Pointer fileInfo; + public int stateAction; + public Pointer stateData; + public WString urlReference; + public int providerFlags; + public int uiContext; + + public WinTrustData() { + } + + WinTrustData(WinTrustFileInfo file) { + cbStruct = size(); + policyCallbackData = Pointer.NULL; + sipClientData = Pointer.NULL; + uiChoice = WTD_UI_NONE; + revocationChecks = WTD_REVOKE_NONE; + unionChoice = WTD_CHOICE_FILE; + fileInfo = file.getPointer(); + stateAction = WTD_STATEACTION_IGNORE; + stateData = Pointer.NULL; + urlReference = null; + providerFlags = 0; + uiContext = 0; + } + + @Override + protected List getFieldOrder() { + return Arrays.asList( + "cbStruct", + "policyCallbackData", + "sipClientData", + "uiChoice", + "revocationChecks", + "unionChoice", + "fileInfo", + "stateAction", + "stateData", + "urlReference", + "providerFlags", + "uiContext"); + } + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngKeyGuard.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngKeyGuard.java new file mode 100644 index 00000000..97bdae30 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngKeyGuard.java @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Memory; +import com.sun.jna.Pointer; +import com.sun.jna.WString; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.PointerByReference; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Windows CNG key operations for mTLS PoP Managed Identity. + * + *

Mirrors msal-go's {@code cng_windows.go} and MSAL.NET's + * {@code WindowsManagedIdentityKeyProvider}: creates or opens a persisted RSA key in + * the Microsoft Software Key Storage Provider, using the same 3-level priority:

+ *
    + *
  1. KeyGuard — Software KSP + USER scope + VBS Virtual Isolation flags. + * Requires Credential Guard / Core Isolation on the VM.
  2. + *
  3. Hardware — Software KSP + USER scope, no VBS flags.
  4. + *
  5. mTLS PoP requires KeyGuard and throws {@link MtlsMsiException} for Hardware keys.
  6. + *
+ */ +final class CngKeyGuard { + + private static final Object ATTESTATION_LIBRARY_LOCK = new Object(); + private static final String MS_SOFTWARE_KSP = "Microsoft Software Key Storage Provider"; + private static final String RSA_ALG = "RSA"; + private static final String EXPORT_POLICY = "Export Policy"; + private static final String KEY_LENGTH = "Length"; + private static final String VIRTUAL_ISO = "Virtual Iso"; + private static final String RSAPUBLICBLOB = "RSAPUBLICBLOB"; + + private CngKeyGuard() {} + + /** + * Gets or creates the mTLS PoP binding key, attempting KeyGuard first. + * + * @param keyName persisted key name in the KSP (e.g. {@code "MSALMtlsKey_"}) + * @return a {@link CngRsaPrivateKey} backed by the CNG handle + * @throws MtlsMsiException if the system is not Windows, the key cannot be created, + * or KeyGuard protection is unavailable + */ + static CngRsaPrivateKey getOrCreateKey(String keyName) throws MtlsMsiException { + if (!isWindows()) { + throw new MtlsMsiException("mTLS PoP Managed Identity is only supported on Windows Azure VMs."); + } + + // 1. Try KeyGuard (USER scope + VBS Virtual Isolation flags). + int kgCreateFlags = NCryptLibrary.NCRYPT_OVERWRITE_KEY_FLAG + | NCryptLibrary.NCRYPT_USE_VIRTUAL_ISOLATION_FLAG + | NCryptLibrary.NCRYPT_USE_PER_BOOT_KEY_FLAG; + try { + CngRsaPrivateKey key = tryGetOrCreateKey(keyName, NCryptLibrary.NCRYPT_SILENT_FLAG, kgCreateFlags, NCryptLibrary.NCRYPT_SILENT_FLAG); + if (isKeyGuardProtected(key.nativeHandle())) { + return key; + } + // Created but VBS protection not active — delete and retry once (mirrors MSAL.NET). + NCryptLibrary.INSTANCE.NCryptDeleteKey(key.nativeHandle(), 0); + key = tryGetOrCreateKey(keyName, NCryptLibrary.NCRYPT_SILENT_FLAG, kgCreateFlags, NCryptLibrary.NCRYPT_SILENT_FLAG); + if (isKeyGuardProtected(key.nativeHandle())) { + return key; + } + key.close(); + } catch (MtlsMsiException ignored) { + // KeyGuard not available on this VM; fall through to error below. + } + + throw new MtlsMsiException( + "mTLS PoP requires a VBS KeyGuard-protected RSA key, but KeyGuard is not available " + + "on this VM. Ensure Credential Guard / Core Isolation is enabled: the VM must be " + + "Trusted Launch (Secure Boot + vTPM) with VBS active " + + "(check msinfo32.exe: 'Virtualization-based security' = Running)."); + } + + /** + * Produces a MAA JWT by calling {@code AttestationClientLib.dll}. + * + * @param keyHandle CNG key handle owned by the binding generation + * @param endpoint MAA attestation endpoint URL (from IMDS platform metadata) + * @param clientId managed identity client ID (from IMDS platform metadata) + * @return the MAA JWT string + * @throws MtlsMsiException if the DLL is not present, or attestation fails + */ + static String getAttestationToken(Pointer keyHandle, String endpoint, String clientId) + throws MtlsMsiException { + synchronized (ATTESTATION_LIBRARY_LOCK) { + return getAttestationTokenSynchronized(keyHandle, endpoint, clientId); + } + } + + private static String getAttestationTokenSynchronized( + Pointer keyHandle, + String endpoint, + String clientId) throws MtlsMsiException { + + AttestationLibrary attestLib = AttestationLibraryLoader.load(); + + AttestationLibrary.AttestationLogInfo logInfo = new AttestationLibrary.AttestationLogInfo(); + int ret = attestLib.InitAttestationLib(logInfo); + if (ret != 0) { + throw new MtlsMsiException( + String.format("InitAttestationLib failed: 0x%x", ret)); + } + + try { + PointerByReference tokenRef = new PointerByReference(); + ret = attestLib.AttestKeyGuardImportKey(endpoint, null, null, keyHandle, tokenRef, clientId); + if (ret != 0) { + throw new MtlsMsiException(String.format( + "AttestKeyGuardImportKey failed (rc=0x%x). This usually means the VM's vTPM " + + "is not provisioned for attestation. mTLS PoP requires a Trusted Launch Azure VM " + + "(Secure Boot + vTPM) with an EK certificate. " + + "Check 'tpmtool.exe getdeviceinformation': 'Is Capable For Attestation' must be true.", ret)); + } + + Pointer tokenPtr = tokenRef.getValue(); + if (tokenPtr == null || tokenPtr == Pointer.NULL) { + throw new MtlsMsiException("AttestKeyGuardImportKey returned null token"); + } + + try { + String jwt = tokenPtr.getString(0); // ANSI (null-terminated) + if (jwt == null || jwt.isEmpty()) { + throw new MtlsMsiException("AttestKeyGuardImportKey returned empty token"); + } + return jwt; + } finally { + attestLib.FreeAttestationToken(tokenPtr); + } + } finally { + attestLib.UninitAttestationLib(); + } + } + + /** + * Signs a digest using {@code NCryptSignHash} with PKCS#1 v1.5 padding. + * + * @param keyHandle CNG key handle + * @param digest the hash bytes to sign + * @param hashAlgCng CNG hash algorithm name (e.g. {@code "SHA256"}) + * @return DER-encoded signature bytes + * @throws MtlsMsiException if signing fails + */ + static byte[] signPkcs1(Pointer keyHandle, byte[] digest, String hashAlgCng) + throws MtlsMsiException { + NCryptLibrary.BcryptPkcs1PaddingInfo padding = + new NCryptLibrary.BcryptPkcs1PaddingInfo(hashAlgCng); + return ncryptSign(keyHandle, padding.getPointer(), NCryptLibrary.NCRYPT_PAD_PKCS1_FLAG, digest, "PKCS1v15"); + } + + /** + * Signs a digest using {@code NCryptSignHash} with RSASSA-PSS padding. + * + * @param keyHandle CNG key handle + * @param digest the hash bytes to sign + * @param hashAlgCng CNG hash algorithm name (e.g. {@code "SHA256"}) + * @param saltLen PSS salt length in bytes + * @return DER-encoded signature bytes + * @throws MtlsMsiException if signing fails + */ + static byte[] signPss(Pointer keyHandle, byte[] digest, String hashAlgCng, int saltLen) + throws MtlsMsiException { + NCryptLibrary.BcryptPssPaddingInfo padding = + new NCryptLibrary.BcryptPssPaddingInfo(hashAlgCng, saltLen); + return ncryptSign(keyHandle, padding.getPointer(), NCryptLibrary.NCRYPT_PAD_PSS_FLAG, digest, "PSS"); + } + + private static byte[] ncryptSign(Pointer hKey, Pointer paddingPtr, int paddingFlag, + byte[] digest, String label) throws MtlsMsiException { + IntByReference sigLen = new IntByReference(0); + // First call: query the signature buffer size. + int ret = NCryptLibrary.INSTANCE.NCryptSignHash( + hKey, paddingPtr, digest, digest.length, + null, 0, sigLen, paddingFlag); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + throw new MtlsMsiException( + String.format("NCryptSignHash %s (size query) failed: 0x%x", label, ret)); + } + + Memory sigBuf = new Memory(sigLen.getValue()); + ret = NCryptLibrary.INSTANCE.NCryptSignHash( + hKey, paddingPtr, digest, digest.length, + sigBuf, sigLen.getValue(), sigLen, paddingFlag); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + throw new MtlsMsiException( + String.format("NCryptSignHash %s failed: 0x%x", label, ret)); + } + + return sigBuf.getByteArray(0, sigLen.getValue()); + } + + // ─── Internal helpers ───────────────────────────────────────────────────── + + private static CngRsaPrivateKey tryGetOrCreateKey(String keyName, + int openFlags, + int createFlags, + int finalizeFlags) throws MtlsMsiException { + Pointer hProvider = openProvider(); + Pointer hKey = null; + boolean created = false; + boolean ownershipTransferred = false; + try { + WString keyNameW = new WString(keyName); + + // 1. Try to open an existing key. + hKey = openExistingUsableKey( + hProvider, + keyNameW, + openFlags, + NCryptLibrary.INSTANCE, + CngKeyGuard::probeOpenedKey); + PointerByReference phKey = new PointerByReference(); + + // 2. Create a new key if open failed. + if (hKey == null) { + int ret = NCryptLibrary.INSTANCE.NCryptCreatePersistedKey( + hProvider, phKey, + new WString(RSA_ALG), + keyNameW, + 0, createFlags); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + throw new MtlsMsiException( + String.format("NCryptCreatePersistedKey failed: 0x%x", ret)); + } + hKey = phKey.getValue(); + created = true; + + // Set key length to 2048. + setDwordProperty(hKey, KEY_LENGTH, 2048); + // Set non-exportable. + setDwordProperty(hKey, EXPORT_POLICY, NCryptLibrary.NCRYPT_ALLOW_EXPORT_NONE); + + ret = NCryptLibrary.INSTANCE.NCryptFinalizeKey(hKey, finalizeFlags); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + NCryptLibrary.INSTANCE.NCryptDeleteKey(hKey, 0); + hKey = null; + throw new MtlsMsiException( + String.format("NCryptFinalizeKey failed: 0x%x. " + + "VBS isolation flags are not supported on this machine " + + "(Credential Guard / Core Isolation not active).", ret)); + } + } + + BigInteger[] pubKey = exportPublicKey(hKey); + CngRsaPrivateKey privateKey = + new CngRsaPrivateKey(hKey, pubKey[0], pubKey[1].intValue()); + ownershipTransferred = true; + return privateKey; + + } finally { + if (!ownershipTransferred && hKey != null) { + if (created) { + NCryptLibrary.INSTANCE.NCryptDeleteKey(hKey, 0); + } else { + NCryptLibrary.INSTANCE.NCryptFreeObject(hKey); + } + } + NCryptLibrary.INSTANCE.NCryptFreeObject(hProvider); + } + } + + static boolean isKeyGuardProtected(Pointer hKey) { + WString propW = new WString(VIRTUAL_ISO); + byte[] buf = new byte[4]; + IntByReference pcbResult = new IntByReference(0); + int ret = NCryptLibrary.INSTANCE.NCryptGetProperty( + hKey, propW, buf, buf.length, pcbResult, 0); + if (ret != NCryptLibrary.ERROR_SUCCESS || pcbResult.getValue() < 4) { + return false; + } + int val = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).getInt(); + return val != 0; + } + + private static void probeKeyLiveness(Pointer hKey) { + signPkcs1(hKey, new byte[32], "SHA256"); + } + + private static void probeOpenedKey(Pointer hKey) { + exportPublicKeyBytes(hKey); + probeKeyLiveness(hKey); + } + + static Pointer openExistingUsableKey( + Pointer provider, + WString keyName, + int openFlags, + NCryptLibrary nativeApi, + KeyProbe probe) { + PointerByReference keyReference = new PointerByReference(); + int result = nativeApi.NCryptOpenKey( + provider, keyReference, keyName, 0, openFlags); + if (result != NCryptLibrary.ERROR_SUCCESS) { + return null; + } + + Pointer key = keyReference.getValue(); + try { + probe.probe(key); + return key; + } catch (MtlsMsiException e) { + nativeApi.NCryptDeleteKey(key, 0); + return null; + } + } + + interface KeyProbe { + void probe(Pointer key); + } + + /** Returns byte[] of the RSAPUBLICBLOB for use in CSR SubjectPublicKeyInfo. */ + static byte[] exportPublicKeyBytes(Pointer hKey) throws MtlsMsiException { + WString blobType = new WString(RSAPUBLICBLOB); + IntByReference pcbResult = new IntByReference(0); + + // Query size. + int ret = NCryptLibrary.INSTANCE.NCryptExportKey( + hKey, null, blobType, null, null, 0, pcbResult, 0); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + throw new MtlsMsiException( + String.format("NCryptExportKey (size query) failed: 0x%x", ret)); + } + + Memory blob = new Memory(pcbResult.getValue()); + ret = NCryptLibrary.INSTANCE.NCryptExportKey( + hKey, null, blobType, null, blob, pcbResult.getValue(), pcbResult, 0); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + throw new MtlsMsiException( + String.format("NCryptExportKey failed: 0x%x", ret)); + } + + return blob.getByteArray(0, pcbResult.getValue()); + } + + /** + * Returns [modulus, publicExponent] parsed from the RSAPUBLICBLOB. + * BCRYPT_RSAKEY_BLOB format (24-byte header): + *
Magic(4) BitLength(4) cbPublicExp(4) cbModulus(4) cbPrime1(4) cbPrime2(4)
+ * followed by PublicExponent bytes then Modulus bytes. + */ + static BigInteger[] exportPublicKey(Pointer hKey) throws MtlsMsiException { + byte[] blob = exportPublicKeyBytes(hKey); + if (blob.length < 24) { + throw new MtlsMsiException("RSAPUBLICBLOB too short: " + blob.length); + } + + ByteBuffer bb = ByteBuffer.wrap(blob).order(ByteOrder.LITTLE_ENDIAN); + bb.getInt(); // magic + bb.getInt(); // bitLength + int cbPublicExp = bb.getInt(); + int cbModulus = bb.getInt(); + // skip cbPrime1, cbPrime2 + bb.position(24); + + byte[] expBytes = new byte[cbPublicExp]; + bb.get(expBytes); + byte[] modBytes = new byte[cbModulus]; + bb.get(modBytes); + + return new BigInteger[] { + new BigInteger(1, modBytes), // [0] = modulus + new BigInteger(1, expBytes) // [1] = publicExponent + }; + } + + private static Pointer openProvider() throws MtlsMsiException { + PointerByReference phProvider = new PointerByReference(); + int ret = NCryptLibrary.INSTANCE.NCryptOpenStorageProvider( + phProvider, new WString(MS_SOFTWARE_KSP), 0); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + throw new MtlsMsiException( + String.format("NCryptOpenStorageProvider failed: 0x%x", ret)); + } + return phProvider.getValue(); + } + + private static void setDwordProperty(Pointer hKey, String propName, int value) + throws MtlsMsiException { + byte[] buf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array(); + int ret = NCryptLibrary.INSTANCE.NCryptSetProperty( + hKey, new WString(propName), buf, buf.length, NCryptLibrary.NCRYPT_SILENT_FLAG); + if (ret != NCryptLibrary.ERROR_SUCCESS) { + throw new MtlsMsiException( + String.format("NCryptSetProperty(%s) failed: 0x%x", propName, ret)); + } + } + + private static boolean isWindows() { + String os = System.getProperty("os.name", "").toLowerCase(); + return os.contains("windows"); + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngProvider.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngProvider.java new file mode 100644 index 00000000..0ba55a19 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngProvider.java @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import java.security.Provider; +import java.security.Security; +import java.util.Arrays; +import java.util.Collections; + +/** + * A {@link Provider} that routes RSA signature operations for {@link CngRsaPrivateKey} + * keys through Windows CNG ({@code NCryptSignHash}) via JNA. + * + *

Install once per JVM via {@link #installIfAbsent()} before creating an + * {@code SSLContext} that uses a {@link CngRsaPrivateKey}. JSSE will call + * {@code Signature.getInstance("SHA256withRSA")} or + * {@code Signature.getInstance("RSASSA-PSS")}; with this provider at + * high priority, {@link CngSignatureSpi} intercepts the call and signs via + * {@code NCryptSignHash} instead of requiring an exportable private exponent.

+ * + *

For non-{@link CngRsaPrivateKey} keys, {@link CngSignatureSpi} automatically + * delegates to the next available provider, so installing this provider does not + * break other RSA signing in the same JVM.

+ */ +public final class CngProvider extends Provider { + + private static final long serialVersionUID = 1L; + private static final String PROVIDER_NAME = "CNG"; + private static final double PROVIDER_VERSION = 1.0; + private static final String PROVIDER_INFO = "Windows CNG JNA provider for JSSE mTLS"; + + public CngProvider() { + super(PROVIDER_NAME, PROVIDER_VERSION, PROVIDER_INFO); + + putSignature("SHA256withRSA", CngSignatureSpi.Sha256WithRsa.class, + "SHA-256withRSA"); + putSignature("SHA384withRSA", CngSignatureSpi.Sha384WithRsa.class, + "SHA-384withRSA"); + putSignature("SHA512withRSA", CngSignatureSpi.Sha512WithRsa.class, + "SHA-512withRSA"); + putSignature("RSASSA-PSS", CngSignatureSpi.RsaSsaPss.class, + "SHA256withRSAandMGF1", "SHA384withRSAandMGF1", "SHA512withRSAandMGF1"); + } + + private void putSignature( + String algorithm, + Class implementation, + String... aliases) { + putService(new CngSignatureService( + this, + algorithm, + implementation.getName(), + Arrays.asList(aliases))); + } + + /** + * Installs this provider at position 1 (highest priority) if it is not already + * registered. Safe to call multiple times. + */ + public static void installIfAbsent() { + if (Security.getProvider(PROVIDER_NAME) == null) { + Security.insertProviderAt(new CngProvider(), 1); + } + } + + private static final class CngSignatureService extends Provider.Service { + + CngSignatureService( + Provider provider, + String algorithm, + String className, + java.util.List aliases) { + super(provider, "Signature", algorithm, className, aliases, + Collections.emptyMap()); + } + + @Override + public boolean supportsParameter(Object parameter) { + return parameter instanceof CngRsaPrivateKey; + } + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngRsaPrivateKey.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngRsaPrivateKey.java new file mode 100644 index 00000000..71fcb3c8 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngRsaPrivateKey.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Pointer; + +import java.math.BigInteger; +import java.security.interfaces.RSAPrivateKey; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A non-exportable RSA private key backed by a Windows CNG {@code NCRYPT_KEY_HANDLE}. + * + *

This key implements {@link RSAPrivateKey} so that JSSE recognizes it as an RSA key + * and selects RSA cipher suites. The private exponent is {@code null} and + * {@link #getEncoded()} returns {@code null} because the key material never leaves the + * CNG key storage provider (KeyGuard VBS isolation).

+ * + *

Signing is performed by {@link CngKeyGuard#signPkcs1} / {@link CngKeyGuard#signPss}, + * dispatched from {@link CngSignatureSpi}.

+ * + *

Callers must call {@link #close()} when done to free the CNG handle.

+ */ +public final class CngRsaPrivateKey implements RSAPrivateKey, AutoCloseable { + + private static final long serialVersionUID = 1L; + + private final Pointer handle; + private final BigInteger modulus; + private final int publicExponent; + private final AtomicBoolean closed = new AtomicBoolean(); + private final Runnable releaser; + + CngRsaPrivateKey(Pointer handle, BigInteger modulus, int publicExponent) { + this(handle, modulus, publicExponent, + () -> NCryptLibrary.INSTANCE.NCryptFreeObject(handle)); + } + + CngRsaPrivateKey( + Pointer handle, + BigInteger modulus, + int publicExponent, + Runnable releaser) { + this.handle = handle; + this.modulus = modulus; + this.publicExponent = publicExponent; + this.releaser = releaser; + } + + Pointer nativeHandle() { + if (closed.get()) throw new IllegalStateException("CNG key handle has been closed"); + return handle; + } + + // ─── RSAKey ─────────────────────────────────────────────────────────────── + + /** Returns the RSA modulus (from the exported RSAPUBLICBLOB — public information). */ + @Override + public BigInteger getModulus() { + return modulus; + } + + /** + * Always returns {@code null}. The private exponent is non-exportable from the + * KeyGuard-protected CNG key; signing is delegated to {@code NCryptSignHash}. + */ + @Override + public BigInteger getPrivateExponent() { + return null; + } + + // ─── Key ────────────────────────────────────────────────────────────────── + + @Override + public String getAlgorithm() { return "RSA"; } + + /** Returns {@code null} — non-exportable key has no serializable encoding. */ + @Override + public String getFormat() { return null; } + + /** Returns {@code null} — non-exportable key has no serializable encoding. */ + @Override + public byte[] getEncoded() { return null; } + + // ─── AutoCloseable ──────────────────────────────────────────────────────── + + /** + * Frees the underlying CNG key handle via {@code NCryptFreeObject}. + * The key remains persisted in the KSP; only the in-process handle is released. + */ + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + releaser.run(); + } + } + + boolean isClosed() { + return closed.get(); + } + + /** The public exponent (e.g. 65537 = 0x10001). */ + public int getPublicExponent() { + return publicExponent; + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngSignatureSpi.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngSignatureSpi.java new file mode 100644 index 00000000..556f293f --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngSignatureSpi.java @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Pointer; + +import java.security.AlgorithmParameters; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.Security; +import java.security.SignatureException; +import java.security.SignatureSpi; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; + +/** + * {@link SignatureSpi} implementations that delegate signing to Windows CNG via JNA. + * + *

Two families are provided:

+ *
    + *
  • {@link Sha256WithRsa} / {@link Sha1WithRsa} — PKCS#1 v1.5 padding
  • + *
  • {@link RsaSsaPss} — RSASSA-PSS with configurable parameters
  • + *
+ * + *

For non-{@link CngRsaPrivateKey} keys, each SPI delegates to the next available + * provider so that installing {@link CngProvider} at high priority does not break other + * code in the same JVM that signs with regular (exportable) RSA keys.

+ */ +abstract class CngSignatureSpi extends SignatureSpi { + + // ─── Concrete algorithms ─────────────────────────────────────────────────── + + /** SHA-256 with RSA PKCS#1 v1.5 */ + public static class Sha256WithRsa extends CngSignatureSpi { + public Sha256WithRsa() { super("SHA-256", "SHA256", false, 32); } + } + + /** SHA-384 with RSA PKCS#1 v1.5 */ + public static class Sha384WithRsa extends CngSignatureSpi { + public Sha384WithRsa() { super("SHA-384", "SHA384", false, 48); } + } + + /** SHA-512 with RSA PKCS#1 v1.5 */ + public static class Sha512WithRsa extends CngSignatureSpi { + public Sha512WithRsa() { super("SHA-512", "SHA512", false, 64); } + } + + /** RSASSA-PSS — algorithm parameters set via {@link #engineSetParameter(AlgorithmParameterSpec)} */ + public static class RsaSsaPss extends CngSignatureSpi { + public RsaSsaPss() { super("SHA-256", "SHA256", true, 32); } + } + + // ─── State ──────────────────────────────────────────────────────────────── + + private final boolean pss; + + // CNG mode + private Pointer cngHandle; + private MessageDigest digest; + private String hashJce; // Java algorithm name (e.g. "SHA-256") + private String hashCng; // CNG algorithm name (e.g. "SHA256") + private int saltLen; + + // Delegation mode (non-CNG keys) + private java.security.Signature delegate; + + CngSignatureSpi(String hashJce, String hashCng, boolean pss, int saltLen) { + this.hashJce = hashJce; + this.hashCng = hashCng; + this.pss = pss; + this.saltLen = saltLen; + } + + // ─── SignatureSpi ───────────────────────────────────────────────────────── + + @Override + protected void engineInitVerify(java.security.PublicKey publicKey) + throws InvalidKeyException { + // CNG only handles signing (NCryptSignHash). For verification (server cert + // validation, etc.) we deliberately throw InvalidKeyException so that + // Signature.Delegate.chooseProvider() skips this SPI and falls through to + // SunRsaSign or another standard provider that handles RSA/ECDSA verification. + throw new InvalidKeyException( + "CngSignatureSpi does not support verification; use SunRsaSign"); + } + + @Override + protected void engineInitSign(PrivateKey key) throws InvalidKeyException { + if (key instanceof CngRsaPrivateKey) { + Pointer h; + try { + h = ((CngRsaPrivateKey) key).nativeHandle(); + } catch (IllegalStateException e) { + throw new InvalidKeyException("CNG key is closed: " + e.getMessage(), e); + } + if (h == null) { + throw new InvalidKeyException("CNG key handle is null (key may be closed or invalid)"); + } + cngHandle = h; + delegate = null; + try { + digest = MessageDigest.getInstance(hashJce); + } catch (NoSuchAlgorithmException e) { + throw new InvalidKeyException("MessageDigest " + hashJce + " not available", e); + } + } else { + // Delegate to the next provider that handles this algorithm. + cngHandle = null; + delegate = null; + try { + delegate = getNextProviderSignature(); + delegate.initSign(key); + } catch (NoSuchAlgorithmException e) { + throw new InvalidKeyException("No fallback provider: " + e.getMessage(), e); + } + } + } + + @Override + protected void engineUpdate(byte b) throws SignatureException { + if (cngHandle != null) { + digest.update(b); + } else if (delegate != null) { + delegate.update(b); + } else { + throw new SignatureException( + "CngSignatureSpi.engineUpdate called before engineInitSign — " + + "Signature object was not properly initialized"); + } + } + + @Override + protected void engineUpdate(byte[] b, int off, int len) throws SignatureException { + if (cngHandle != null) { + digest.update(b, off, len); + } else if (delegate != null) { + delegate.update(b, off, len); + } else { + throw new SignatureException( + "CngSignatureSpi.engineUpdate called before engineInitSign — " + + "Signature object was not properly initialized"); + } + } + + @Override + protected byte[] engineSign() throws SignatureException { + if (cngHandle != null) { + byte[] hash = digest.digest(); + try { + if (pss) { + return CngKeyGuard.signPss(cngHandle, hash, hashCng, saltLen); + } else { + return CngKeyGuard.signPkcs1(cngHandle, hash, hashCng); + } + } catch (MtlsMsiException e) { + throw new SignatureException("CNG signing failed: " + e.getMessage(), e); + } + } else { + return delegate.sign(); + } + } + + @Override + protected boolean engineVerify(byte[] sigBytes) throws SignatureException { + if (delegate != null) { + return delegate.verify(sigBytes); + } + // Verification is not needed for client-auth TLS or CSR generation. + throw new SignatureException("CngSignatureSpi does not support verify (CNG-backed keys)"); + } + + @Override + protected void engineSetParameter(AlgorithmParameterSpec params) + throws InvalidAlgorithmParameterException { + if (!pss) { + if (params != null) { + throw new InvalidAlgorithmParameterException( + "Parameters are not supported for PKCS#1 RSA signatures."); + } + return; + } + if (params instanceof PSSParameterSpec) { + PSSParameterSpec pssSpec = (PSSParameterSpec) params; + validatePssParameters(pssSpec); + hashJce = pssSpec.getDigestAlgorithm(); + hashCng = toCngHashName(pssSpec.getDigestAlgorithm()); + saltLen = pssSpec.getSaltLength(); + if (cngHandle != null) { + // Re-initialize the digest with the new hash algorithm. + try { + digest = MessageDigest.getInstance(hashJce); + } catch (NoSuchAlgorithmException e) { + throw new InvalidAlgorithmParameterException( + "MessageDigest " + hashJce + " not available", e); + } + } else if (delegate != null) { + // Forward PSS params to the delegating provider's Signature instance. + try { + delegate.setParameter(params); + } catch (Exception e) { + throw new InvalidAlgorithmParameterException(e.getMessage(), e); + } + } + } else { + throw new InvalidAlgorithmParameterException( + "RSASSA-PSS requires PSSParameterSpec."); + } + } + + @Override + @SuppressWarnings("deprecation") + protected void engineSetParameter(String param, Object value) { + // Legacy method — no-op, required by abstract superclass. + } + + @Override + @SuppressWarnings("deprecation") + protected Object engineGetParameter(String param) { + return null; + } + + @Override + protected AlgorithmParameters engineGetParameters() { + if (pss && delegate == null) { + try { + AlgorithmParameters ap = AlgorithmParameters.getInstance("RSASSA-PSS"); + ap.init(new PSSParameterSpec(hashJce, "MGF1", + new MGF1ParameterSpec(hashJce), saltLen, 1)); + return ap; + } catch (Exception e) { + return null; + } + } + if (delegate != null) { + return delegate.getParameters(); + } + return null; + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private java.security.Signature getNextProviderSignature() throws NoSuchAlgorithmException { + String algName = pss ? "RSASSA-PSS" : hashCng + "withRSA"; + for (Provider p : Security.getProviders()) { + if (p instanceof CngProvider) continue; + if (p.getService("Signature", algName) != null) { + return java.security.Signature.getInstance(algName, p); + } + } + throw new NoSuchAlgorithmException( + "No provider for " + algName + " besides CngProvider"); + } + + private static String toCngHashName(String jceHashName) + throws InvalidAlgorithmParameterException { + if (jceHashName == null) { + throw new InvalidAlgorithmParameterException("PSS digest must be specified."); + } + switch (jceHashName.toUpperCase().replace("-", "")) { + case "SHA256": return "SHA256"; + case "SHA384": return "SHA384"; + case "SHA512": return "SHA512"; + default: + throw new InvalidAlgorithmParameterException( + "Unsupported RSA digest: " + jceHashName); + } + } + + private static void validatePssParameters(PSSParameterSpec spec) + throws InvalidAlgorithmParameterException { + String hash = toCngHashName(spec.getDigestAlgorithm()); + if (!"MGF1".equalsIgnoreCase(spec.getMGFAlgorithm())) { + throw new InvalidAlgorithmParameterException( + "Only MGF1 is supported for RSASSA-PSS."); + } + if (!(spec.getMGFParameters() instanceof MGF1ParameterSpec)) { + throw new InvalidAlgorithmParameterException( + "MGF1 parameters must be MGF1ParameterSpec."); + } + String mgfHash = toCngHashName( + ((MGF1ParameterSpec) spec.getMGFParameters()).getDigestAlgorithm()); + if (!hash.equals(mgfHash)) { + throw new InvalidAlgorithmParameterException( + "PSS digest and MGF1 digest must match."); + } + int expectedSaltLength = "SHA256".equals(hash) ? 32 + : "SHA384".equals(hash) ? 48 : 64; + if (spec.getSaltLength() != expectedSaltLength) { + throw new InvalidAlgorithmParameterException( + "PSS salt length must equal the digest length."); + } + if (spec.getTrailerField() != 1) { + throw new InvalidAlgorithmParameterException( + "Only trailer field 1 is supported for RSASSA-PSS."); + } + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngX509ExtendedKeyManager.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngX509ExtendedKeyManager.java new file mode 100644 index 00000000..77a993d3 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/CngX509ExtendedKeyManager.java @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import java.net.Socket; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +final class CngX509ExtendedKeyManager extends X509ExtendedKeyManager { + + private static final String ALIAS = "msal-keyguard-mtls"; + private final CngRsaPrivateKey privateKey; + private final X509Certificate[] certificateChain; + + CngX509ExtendedKeyManager( + CngRsaPrivateKey privateKey, + X509Certificate certificate) { + this.privateKey = privateKey; + this.certificateChain = new X509Certificate[]{certificate}; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return supportsKeyType(keyType) ? new String[]{ALIAS} : null; + } + + @Override + public String chooseClientAlias( + String[] keyTypes, + Principal[] issuers, + Socket socket) { + return supportsAnyKeyType(keyTypes) ? ALIAS : null; + } + + @Override + public String chooseEngineClientAlias( + String[] keyTypes, + Principal[] issuers, + SSLEngine engine) { + return supportsAnyKeyType(keyTypes) ? ALIAS : null; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return ALIAS.equals(alias) ? certificateChain.clone() : null; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return ALIAS.equals(alias) ? privateKey : null; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseServerAlias( + String keyType, + Principal[] issuers, + Socket socket) { + return null; + } + + @Override + public String chooseEngineServerAlias( + String keyType, + Principal[] issuers, + SSLEngine engine) { + return null; + } + + private static boolean supportsAnyKeyType(String[] keyTypes) { + if (keyTypes == null) { + return false; + } + for (String keyType : keyTypes) { + if (supportsKeyType(keyType)) { + return true; + } + } + return false; + } + + private static boolean supportsKeyType(String keyType) { + return keyType != null + && ("RSA".equalsIgnoreCase(keyType) + || "RSASSA-PSS".equalsIgnoreCase(keyType)); + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/ImdsV2Client.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/ImdsV2Client.java new file mode 100644 index 00000000..60418efe --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/ImdsV2Client.java @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.microsoft.aad.msal4j.ManagedIdentityMtlsHttpRequest; +import com.microsoft.aad.msal4j.ManagedIdentityMtlsHttpResponse; +import com.microsoft.aad.msal4j.ManagedIdentityMtlsRequest; + +import java.net.URLEncoder; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class ImdsV2Client { + + private static final String IMDS_BASE = "http://169.254.169.254"; + private static final String PLATFORM_METADATA_PATH = + "/metadata/identity/getplatformmetadata"; + private static final String ISSUE_CREDENTIAL_PATH = + "/metadata/identity/issuecredential"; + private static final String API_VERSION_QUERY = "cred-api-version=2.0"; + + private ImdsV2Client() { + } + + static PlatformMetadata getPlatformMetadata(ManagedIdentityMtlsRequest request) { + ManagedIdentityMtlsHttpResponse httpResponse = execute( + request, + "GET", + buildUrl(PLATFORM_METADATA_PATH, request), + null); + validateImdsOrigin(httpResponse); + String response = httpResponse.body(); + PlatformMetadata metadata = new PlatformMetadata( + extractString(response, "clientId"), + extractString(response, "tenantId"), + extractNestedString(response, "cuId", "vmId"), + extractNestedString(response, "cuId", "vmssId"), + extractString(response, "attestationEndpoint")); + if (isBlank(metadata.clientId) + || isBlank(metadata.tenantId) + || isBlank(metadata.cuId()) + || (request.attestationEnabled() + && isBlank(metadata.attestationEndpoint))) { + throw new MtlsMsiException( + "IMDS getplatformmetadata returned an incomplete attested KeyGuard contract."); + } + return metadata; + } + + static CredentialResponse issueCredential( + ManagedIdentityMtlsRequest request, + String csr, + String attestationToken) { + if (request.attestationEnabled() && isBlank(attestationToken)) { + throw new MtlsMsiException( + "KeyGuard attestation failed; no attestation token was produced."); + } + String body = "{\"csr\":\"" + escapeJson(csr) + "\"" + + (isBlank(attestationToken) + ? "" + : ",\"attestation_token\":\"" + + escapeJson(attestationToken) + "\"") + + "}"; + String response = execute( + request, + "POST", + buildUrl(ISSUE_CREDENTIAL_PATH, request), + body).body(); + CredentialResponse credential = new CredentialResponse( + extractString(response, "certificate"), + extractString(response, "mtls_authentication_endpoint"), + extractString(response, "client_id"), + extractString(response, "tenant_id"), + extractString(response, "identity_type")); + if (isBlank(credential.certificate) + || isBlank(credential.mtlsAuthenticationEndpoint) + || isBlank(credential.clientId) + || isBlank(credential.tenantId) + || isBlank(credential.identityType)) { + throw new MtlsMsiException( + "IMDS issuecredential returned an incomplete binding credential."); + } + return credential; + } + + private static ManagedIdentityMtlsHttpResponse execute( + ManagedIdentityMtlsRequest request, + String method, + String url, + String body) { + Map headers = new LinkedHashMap<>(); + headers.put("Metadata", "true"); + headers.put("x-ms-client-request-id", request.correlationId()); + if (body != null) { + headers.put("Content-Type", "application/json"); + } + ManagedIdentityMtlsHttpResponse response = request.httpClient().execute( + new ManagedIdentityMtlsHttpRequest(method, url, headers, body)); + if (response.statusCode() != 200) { + throw new MtlsMsiException( + "IMDS " + method + " " + url + " failed with HTTP " + + response.statusCode() + "."); + } + return response; + } + + private static void validateImdsOrigin( + ManagedIdentityMtlsHttpResponse response) { + for (Map.Entry> header + : response.headers().entrySet()) { + if (header.getKey() == null + || !"server".equalsIgnoreCase(header.getKey())) { + continue; + } + if (header.getValue() == null) { + continue; + } + for (String value : header.getValue()) { + String normalized = value == null + ? "" : value.trim().toUpperCase(); + if ("IMDS".equals(normalized) + || normalized.startsWith("IMDS/")) { + return; + } + } + } + throw new MtlsMsiException( + "IMDS getplatformmetadata response did not contain the expected Server header."); + } + + private static String buildUrl(String path, ManagedIdentityMtlsRequest request) { + StringBuilder url = new StringBuilder(IMDS_BASE) + .append(path) + .append('?') + .append(API_VERSION_QUERY); + if (!isBlank(request.identityQueryParameter())) { + url.append('&') + .append(encode(request.identityQueryParameter())) + .append('=') + .append(encode(request.identityQueryValue())); + } + return url.toString(); + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (Exception e) { + throw new MtlsMsiException("Unable to encode IMDS query parameter.", e); + } + } + + static String extractString(String json, String key) { + if (json == null) { + return null; + } + String marker = "\"" + key + "\""; + int keyIndex = json.indexOf(marker); + if (keyIndex < 0) { + return null; + } + int colon = json.indexOf(':', keyIndex + marker.length()); + if (colon < 0) { + return null; + } + int quote = skipWhitespace(json, colon + 1); + if (quote >= json.length() || json.charAt(quote) != '"') { + return null; + } + StringBuilder value = new StringBuilder(); + for (int i = quote + 1; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '"') { + return value.toString(); + } + if (c == '\\' && i + 1 < json.length()) { + char escaped = json.charAt(++i); + value.append(escaped == 'n' ? '\n' + : escaped == 'r' ? '\r' + : escaped == 't' ? '\t' : escaped); + } else { + value.append(c); + } + } + return null; + } + + private static String extractNestedString(String json, String objectKey, String key) { + String marker = "\"" + objectKey + "\""; + int objectIndex = json == null ? -1 : json.indexOf(marker); + if (objectIndex < 0) { + return null; + } + int start = json.indexOf('{', objectIndex + marker.length()); + if (start < 0) { + return null; + } + int end = json.indexOf('}', start + 1); + return end < 0 ? null : extractString(json.substring(start, end + 1), key); + } + + private static int skipWhitespace(String value, int index) { + while (index < value.length() && Character.isWhitespace(value.charAt(index))) { + index++; + } + return index; + } + + private static String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + static final class PlatformMetadata { + final String clientId; + final String tenantId; + final String vmId; + final String vmssId; + final String attestationEndpoint; + + PlatformMetadata( + String clientId, + String tenantId, + String vmId, + String vmssId, + String attestationEndpoint) { + this.clientId = clientId; + this.tenantId = tenantId; + this.vmId = vmId; + this.vmssId = vmssId; + this.attestationEndpoint = attestationEndpoint; + } + + String cuId() { + return isBlank(vmId) ? vmssId : vmId; + } + } + + static final class CredentialResponse { + final String certificate; + final String mtlsAuthenticationEndpoint; + final String clientId; + final String tenantId; + final String identityType; + + CredentialResponse( + String certificate, + String mtlsAuthenticationEndpoint, + String clientId, + String tenantId, + String identityType) { + this.certificate = certificate; + this.mtlsAuthenticationEndpoint = mtlsAuthenticationEndpoint; + this.clientId = clientId; + this.tenantId = tenantId; + this.identityType = identityType; + } + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/KeyGuardManagedIdentityMtlsProvider.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/KeyGuardManagedIdentityMtlsProvider.java new file mode 100644 index 00000000..7eb023b2 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/KeyGuardManagedIdentityMtlsProvider.java @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.microsoft.aad.msal4j.IManagedIdentityMtlsProvider; +import com.microsoft.aad.msal4j.ManagedIdentityMtlsBinding; +import com.microsoft.aad.msal4j.ManagedIdentityMtlsRequest; +import com.microsoft.aad.msal4j.MtlsBindingStrength; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.interfaces.RSAPublicKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Windows KeyGuard/MAA implementation of the managed identity mTLS provider SPI. + */ +public final class KeyGuardManagedIdentityMtlsProvider + implements IManagedIdentityMtlsProvider { + + private static final long ROTATION_BUFFER_MILLIS = 24L * 60L * 60L * 1000L; + private static final AttestationTokenCache ATTESTATION_CACHE = + new AttestationTokenCache(); + private static final Map CURRENT = + new ConcurrentHashMap<>(); + private static final Map> RETIRED = + new ConcurrentHashMap<>(); + private static final Map LOCKS = new ConcurrentHashMap<>(); + + @Override + public ManagedIdentityMtlsBinding getOrCreateBinding( + ManagedIdentityMtlsRequest request) { + validateRequest(request); + String cacheKey = request.bindingCacheKey(); + BindingGeneration cached = CURRENT.get(cacheKey); + if (canReturnWithoutCleanup( + cached, + RETIRED.containsKey(cacheKey))) { + return cached.binding; + } + + Object lock = LOCKS.computeIfAbsent(cacheKey, ignored -> new Object()); + synchronized (lock) { + cleanupRetired(cacheKey); + cached = CURRENT.get(cacheKey); + if (isCurrent(cached)) { + return cached.binding; + } + BindingGeneration created = createBinding(request); + BindingGeneration previous = CURRENT.put(cacheKey, created); + if (previous != null) { + RETIRED.computeIfAbsent(cacheKey, ignored -> new ArrayList<>()) + .add(previous); + } + return created.binding; + } + } + + @Override + public MtlsBindingStrength getMaxSupportedBindingStrength( + ManagedIdentityMtlsRequest request) { + validateRequest(request); + ImdsV2Client.PlatformMetadata metadata = + ImdsV2Client.getPlatformMetadata(request); + validateSelectedIdentity(request, metadata); + CngRsaPrivateKey privateKey = CngKeyGuard.getOrCreateKey( + keyName(request, metadata)); + try { + CngKeyGuard.exportPublicKey(privateKey.nativeHandle()); + return MtlsBindingStrength.KEY_GUARD; + } finally { + privateKey.close(); + } + } + + private static BindingGeneration createBinding( + ManagedIdentityMtlsRequest request) { + ImdsV2Client.PlatformMetadata metadata = + ImdsV2Client.getPlatformMetadata(request); + validateSelectedIdentity(request, metadata); + + CngRsaPrivateKey privateKey = CngKeyGuard.getOrCreateKey( + keyName(request, metadata)); + try { + BigInteger[] publicKey = + CngKeyGuard.exportPublicKey(privateKey.nativeHandle()); + String attestationKeyId = hashBytes( + CngKeyGuard.exportPublicKeyBytes(privateKey.nativeHandle())); + String csr = Pkcs10Builder.generate( + privateKey.nativeHandle(), + publicKey[0], + publicKey[1].intValue(), + metadata.clientId, + metadata.tenantId, + metadata.vmId, + metadata.vmssId); + String attestationToken = request.attestationEnabled() + ? ATTESTATION_CACHE.getOrAttest( + metadata.attestationEndpoint, + attestationKeyId, + () -> CngKeyGuard.getAttestationToken( + privateKey.nativeHandle(), + metadata.attestationEndpoint, + metadata.clientId)) + : null; + ImdsV2Client.CredentialResponse credential = + ImdsV2Client.issueCredential(request, csr, attestationToken); + validateCredential(metadata, request, credential); + + X509Certificate certificate = parseCertificate(credential.certificate); + validateCertificateMatchesKey(certificate, publicKey); + KeyGuardMtlsBindingContext context = + new KeyGuardMtlsBindingContext(privateKey, certificate); + String endpoint = trimTrailingSlash( + credential.mtlsAuthenticationEndpoint) + + "/" + trimSlashes(credential.tenantId) + + "/oauth2/v2.0/token"; + ManagedIdentityMtlsBinding binding = new ManagedIdentityMtlsBinding( + context, + credential.clientId, + endpoint); + return new BindingGeneration(binding, context, certificate.getNotAfter().getTime()); + } catch (RuntimeException e) { + privateKey.close(); + throw e; + } + } + + private static void validateRequest(ManagedIdentityMtlsRequest request) { + if (request == null + || request.httpClient() == null + || isBlank(request.bindingCacheKey()) + || isBlank(request.correlationId())) { + throw new MtlsMsiException( + "Managed identity mTLS provider request is incomplete."); + } + if ((request.identityQueryParameter() == null) + != (request.identityQueryValue() == null)) { + throw new MtlsMsiException( + "Managed identity selector name and value must be supplied together."); + } + } + + private static void validateSelectedIdentity( + ManagedIdentityMtlsRequest request, + ImdsV2Client.PlatformMetadata metadata) { + if ("client_id".equals(request.identityQueryParameter()) + && !metadata.clientId.equalsIgnoreCase(request.identityQueryValue())) { + throw new MtlsMsiException( + "IMDS returned a different managed identity than the requested client ID."); + } + } + + private static void validateCredential( + ImdsV2Client.PlatformMetadata metadata, + ManagedIdentityMtlsRequest request, + ImdsV2Client.CredentialResponse credential) { + if (!metadata.clientId.equalsIgnoreCase(credential.clientId) + || !metadata.tenantId.equalsIgnoreCase(credential.tenantId)) { + throw new MtlsMsiException( + "IMDS issuecredential identity does not match platform metadata."); + } + boolean systemAssigned = request.identityQueryParameter() == null; + String expectedIdentityType = + systemAssigned ? "SystemAssigned" : "UserAssigned"; + if (!expectedIdentityType.equalsIgnoreCase(credential.identityType)) { + throw new MtlsMsiException( + "IMDS issuecredential returned unexpected identity_type '" + + credential.identityType + "'."); + } + } + + private static X509Certificate parseCertificate(String encoded) { + try { + byte[] der = Base64.getDecoder().decode(encoded); + return (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(der)); + } catch (Exception e) { + throw new MtlsMsiException( + "Unable to parse the IMDS binding certificate.", e); + } + } + + private static void validateCertificateMatchesKey( + X509Certificate certificate, + BigInteger[] publicKey) { + if (!(certificate.getPublicKey() instanceof RSAPublicKey)) { + throw new MtlsMsiException( + "IMDS binding certificate does not contain an RSA public key."); + } + RSAPublicKey certificateKey = (RSAPublicKey) certificate.getPublicKey(); + if (!publicKey[0].equals(certificateKey.getModulus()) + || !publicKey[1].equals(certificateKey.getPublicExponent())) { + throw new MtlsMsiException( + "IMDS binding certificate does not match the KeyGuard key."); + } + } + + private static boolean isCurrent(BindingGeneration generation) { + return generation != null + && isCertificateCurrent( + generation.notAfterMillis, + System.currentTimeMillis()); + } + + static boolean canReturnWithoutCleanup( + BindingGeneration generation, + boolean hasRetiredGenerations) { + return isCurrent(generation) && !hasRetiredGenerations; + } + + static boolean isCertificateCurrent(long notAfterMillis, long nowMillis) { + return nowMillis < notAfterMillis - ROTATION_BUFFER_MILLIS; + } + + private static void cleanupRetired(String cacheKey) { + List generations = RETIRED.get(cacheKey); + if (generations == null) { + return; + } + long now = System.currentTimeMillis(); + List retained = new ArrayList<>(); + for (BindingGeneration generation : generations) { + if (now >= generation.notAfterMillis) { + generation.context.closeNativeKey(); + } else { + retained.add(generation); + } + } + if (retained.isEmpty()) { + RETIRED.remove(cacheKey); + } else { + RETIRED.put(cacheKey, retained); + } + } + + private static String shortHash(String value) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(hash).substring(0, 32); + } catch (Exception e) { + throw new MtlsMsiException("Unable to derive the KeyGuard key name.", e); + } + } + + private static String keyName( + ManagedIdentityMtlsRequest request, + ImdsV2Client.PlatformMetadata metadata) { + return "MSALJavaMtls_" + shortHash( + request.bindingCacheKey() + "|" + metadata.cuId()); + } + + private static String hashBytes(byte[] value) { + try { + return Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(value)); + } catch (Exception e) { + throw new MtlsMsiException( + "Unable to derive the KeyGuard attestation cache key.", e); + } + } + + private static String trimTrailingSlash(String value) { + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value; + } + + private static String trimSlashes(String value) { + while (value.startsWith("/")) { + value = value.substring(1); + } + return trimTrailingSlash(value); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + static final class BindingGeneration { + final ManagedIdentityMtlsBinding binding; + final KeyGuardMtlsBindingContext context; + final long notAfterMillis; + + BindingGeneration( + ManagedIdentityMtlsBinding binding, + KeyGuardMtlsBindingContext context, + long notAfterMillis) { + this.binding = binding; + this.context = context; + this.notAfterMillis = notAfterMillis; + } + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContext.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContext.java new file mode 100644 index 00000000..bb3f4ebf --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContext.java @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.microsoft.aad.msal4j.IMtlsBindingContext; +import com.microsoft.aad.msal4j.MtlsBindingStrength; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedKeyManager; +import java.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.util.Base64; + +final class KeyGuardMtlsBindingContext implements IMtlsBindingContext { + + private final CngRsaPrivateKey privateKey; + private final X509Certificate certificate; + private final X509ExtendedKeyManager keyManager; + private final SSLContext sslContext; + private final String keyId; + + KeyGuardMtlsBindingContext( + CngRsaPrivateKey privateKey, + X509Certificate certificate) { + this.privateKey = privateKey; + this.certificate = certificate; + this.keyId = calculateKeyId(certificate); + this.keyManager = + new CngX509ExtendedKeyManager(privateKey, certificate); + try { + this.sslContext = createSslContext(keyManager, null); + } catch (RuntimeException e) { + privateKey.close(); + throw e; + } + } + + @Override + public SSLContext sslContext() { + return sslContext; + } + + @Override + public MtlsBindingStrength bindingStrength() { + return MtlsBindingStrength.KEY_GUARD; + } + + @Override + public X509ExtendedKeyManager keyManager() { + return keyManager; + } + + @Override + public X509Certificate bindingCertificate() { + return certificate; + } + + @Override + public String keyId() { + return keyId; + } + + void closeNativeKey() { + privateKey.close(); + } + + static SSLContext createSslContext( + X509ExtendedKeyManager keyManager, + TrustManager[] trustManagers) { + try { + CngProvider.installIfAbsent(); + SSLContext context = SSLContext.getInstance("TLSv1.2"); + context.init( + new X509ExtendedKeyManager[]{keyManager}, + trustManagers, + null); + return context; + } catch (Exception e) { + throw new MtlsMsiException("Unable to create mTLS JSSE SSLContext.", e); + } + } + + static String calculateKeyId(X509Certificate certificate) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(certificate.getEncoded()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (Exception e) { + throw new MtlsMsiException("Unable to calculate binding certificate key ID.", e); + } + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/MtlsMsiException.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/MtlsMsiException.java new file mode 100644 index 00000000..a788f1de --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/MtlsMsiException.java @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +/** + * Thrown when the KeyGuard-backed managed identity mTLS binding flow fails. + */ +public class MtlsMsiException extends RuntimeException { + + public MtlsMsiException(String message) { + super(message); + } + + public MtlsMsiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/NCryptLibrary.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/NCryptLibrary.java new file mode 100644 index 00000000..b1ab7148 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/NCryptLibrary.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; +import com.sun.jna.WString; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.PointerByReference; + +import java.util.Arrays; +import java.util.List; + +/** + * JNA binding for {@code ncrypt.dll} — Windows CNG (Cryptography Next Generation) key + * storage and signing operations. + * + *

Function signatures mirror MSAL.NET's {@code WindowsCngKeyOperations} and msal-go's + * {@code cng_windows.go}. All NCrypt functions follow the Windows x64 calling convention + * (which equals cdecl on x64).

+ */ +interface NCryptLibrary extends Library { + + NCryptLibrary INSTANCE = Native.load("ncrypt", NCryptLibrary.class); + + // ─── NCrypt constants ────────────────────────────────────────────────────── + + int ERROR_SUCCESS = 0; + + int NCRYPT_SILENT_FLAG = 0x00000040; + int NCRYPT_OVERWRITE_KEY_FLAG = 0x00000080; + int NCRYPT_MACHINE_KEY_FLAG = 0x00000020; // not used (USER scope only) + int NCRYPT_USE_VIRTUAL_ISOLATION_FLAG = 0x00020000; // VBS KeyGuard + int NCRYPT_USE_PER_BOOT_KEY_FLAG = 0x00040000; // ephemeral per boot + int NCRYPT_ALLOW_EXPORT_NONE = 0; // non-exportable + + int NCRYPT_PAD_PKCS1_FLAG = 0x00000002; + int NCRYPT_PAD_PSS_FLAG = 0x00000008; + + // ─── Padding info structures ─────────────────────────────────────────────── + + /** Maps to {@code BCRYPT_PKCS1_PADDING_INFO} — used with NCRYPT_PAD_PKCS1_FLAG. */ + class BcryptPkcs1PaddingInfo extends Structure { + /** Algorithm name for the hash (e.g. L"SHA256"). LPCWSTR in C. */ + public WString pszAlgId; + + public BcryptPkcs1PaddingInfo(String algName) { + pszAlgId = new WString(algName); + write(); + } + + @Override + protected List getFieldOrder() { + return Arrays.asList("pszAlgId"); + } + } + + /** Maps to {@code BCRYPT_PSS_PADDING_INFO} — used with NCRYPT_PAD_PSS_FLAG. */ + class BcryptPssPaddingInfo extends Structure { + /** Algorithm name for the hash (e.g. L"SHA256"). LPCWSTR in C. */ + public WString pszAlgId; + /** Salt length in bytes. Typically equals hash output length for RSASSA-PSS. */ + public int cbSalt; + + public BcryptPssPaddingInfo(String algName, int saltLen) { + pszAlgId = new WString(algName); + cbSalt = saltLen; + write(); + } + + @Override + protected List getFieldOrder() { + return Arrays.asList("pszAlgId", "cbSalt"); + } + } + + // ─── NCrypt API ──────────────────────────────────────────────────────────── + + int NCryptOpenStorageProvider(PointerByReference phProvider, WString pszProviderName, int dwFlags); + + int NCryptOpenKey(Pointer hProvider, PointerByReference phKey, WString pszKeyName, + int dwLegacyKeySpec, int dwFlags); + + int NCryptCreatePersistedKey(Pointer hProvider, PointerByReference phKey, + WString pszAlgId, WString pszKeyName, + int dwLegacyKeySpec, int dwFlags); + + int NCryptSetProperty(Pointer hObject, WString pszProperty, + byte[] pbInput, int cbInput, int dwFlags); + + int NCryptGetProperty(Pointer hObject, WString pszProperty, + byte[] pbOutput, int cbOutput, + IntByReference pcbResult, int dwFlags); + + int NCryptFinalizeKey(Pointer hKey, int dwFlags); + + /** First call: pass {@code pbOutput=null, cbOutput=0} to query required buffer size. */ + int NCryptExportKey(Pointer hKey, Pointer hExportKey, WString pszBlobType, + Pointer pParameterList, Pointer pbOutput, int cbOutput, + IntByReference pcbResult, int dwFlags); + + /** + * First call: pass {@code pbSignature=null, cbSignature=0} to get required buffer size + * (returned in {@code pcbResult}). + * Second call: pass a {@code Memory} buffer of that size. + */ + int NCryptSignHash(Pointer hKey, Pointer pPaddingInfo, + byte[] pbHashValue, int cbHashValue, + Pointer pbSignature, int cbSignature, + IntByReference pcbResult, int dwFlags); + + int NCryptFreeObject(Pointer hObject); + + int NCryptDeleteKey(Pointer hKey, int dwFlags); +} diff --git a/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/Pkcs10Builder.java b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/Pkcs10Builder.java new file mode 100644 index 00000000..3cc8e919 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/Pkcs10Builder.java @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Pointer; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * Builds a PKCS#10 Certification Request (CSR) that matches the format produced by + * MSAL.NET and msal-go for the Azure IMDSv2 {@code /issuecredential} endpoint. + * + *

CSR Structure

+ *
+ * CertificationRequest ::= SEQUENCE {
+ *     certificationRequestInfo  CertificationRequestInfo,
+ *     signatureAlgorithm        AlgorithmIdentifier,   -- RSASSA-PSS with SHA-256 params
+ *     signature                 BIT STRING
+ * }
+ *
+ * CertificationRequestInfo ::= SEQUENCE {
+ *     version       INTEGER { v1(0) }
+ *     subject       Name   -- CN={clientId}, DC={tenantId}
+ *     subjectPKInfo SubjectPublicKeyInfo
+ *     attributes    [0] IMPLICIT SET OF -- OID 1.3.6.1.4.1.311.90.2.10 = cuId JSON
+ * }
+ * 
+ * + *

Signing

+ * Signature: RSASSA-PSS with SHA-256, salt length = 32 bytes (hash output length). + * Signing is delegated to {@link CngKeyGuard#signPss} so the non-exportable KeyGuard key + * never leaves CNG. + * + *

This is a pure-Java port of msal-go's {@code generateCSR()} in {@code imdsv2.go}, + * using manual DER encoding to avoid adding external ASN.1 library dependencies.

+ */ +final class Pkcs10Builder { + + private Pkcs10Builder() {} + + // ─── OIDs (pre-encoded DER) ──────────────────────────────────────────────── + + // rsaEncryption: 1.2.840.113549.1.1.1 + private static final byte[] OID_RSA_ENCRYPTION = hexToBytes("2a864886f70d010101"); + // sha256: 2.16.840.1.101.3.4.2.1 + private static final byte[] OID_SHA256 = hexToBytes("608648016503040201"); + // mgf1: 1.2.840.113549.1.1.8 + private static final byte[] OID_MGF1 = hexToBytes("2a864886f70d010108"); + // id-RSASSA-PSS: 1.2.840.113549.1.1.10 + private static final byte[] OID_RSASSA_PSS = hexToBytes("2a864886f70d01010a"); + // commonName: 2.5.4.3 + private static final byte[] OID_COMMON_NAME = hexToBytes("5504 03".replace(" ", "")); + // domainComponent: 0.9.2342.19200300.100.1.25 + private static final byte[] OID_DOMAIN_COMPONENT = hexToBytes("0992268993f22c6401 19".replace(" ", "")); + // cuId attribute: 1.3.6.1.4.1.311.90.2.10 + private static final byte[] OID_CU_ID = hexToBytes("2b060104018237 5a02 0a".replace(" ", "")); + + // ─── Public API ─────────────────────────────────────────────────────────── + + /** + * Generates a PKCS#10 CSR and returns it as standard Base64-encoded DER + * (no PEM headers), ready to be placed in the {@code csr} field of the + * IMDS {@code /issuecredential} JSON request. + * + * @param keyHandle CNG key handle (the private key — signs the CSR TBS bytes) + * @param modulus RSA public key modulus (from {@link CngKeyGuard#exportPublicKey}) + * @param publicExp RSA public exponent + * @param clientId managed identity client ID → CN in subject + * @param tenantId tenant GUID → DC in subject + * @param vmId VM ID for the cuId attribute ({@code cuId.vmId}); may be null + * @param vmssId VMSS ID for the cuId attribute; may be null + * @return Base64-encoded DER of the PKCS#10 CSR + */ + static String generate(Pointer keyHandle, BigInteger modulus, int publicExp, + String clientId, String tenantId, String vmId, String vmssId) + throws MtlsMsiException { + + // --- SubjectPublicKeyInfo ------------------------------------------ + byte[] spki = buildSpki(modulus, publicExp); + + // --- Subject: CN={clientId}, DC={tenantId} ------------------------- + byte[] subject = buildSubject(clientId, tenantId); + + // --- cuId attribute ------------------------------------------------ + byte[] cuIdJson = buildCuIdJson(vmId, vmssId); + byte[] attributes = buildCuIdAttribute(cuIdJson); + + // --- CertificationRequestInfo SEQUENCE ----------------------------- + byte[] version = derInteger(new byte[]{0x00}); // INTEGER v1(0) + byte[] certReqInfo = derSequence(concat(version, subject, spki, attributes)); + + // --- Sign with RSASSA-PSS SHA-256 (salt=32) ----------------------- + byte[] tbs; + try { + tbs = MessageDigest.getInstance("SHA-256").digest(certReqInfo); + } catch (NoSuchAlgorithmException e) { + throw new MtlsMsiException("SHA-256 not available: " + e.getMessage(), e); + } + byte[] sig = CngKeyGuard.signPss(keyHandle, tbs, "SHA256", 32); + + // --- AlgorithmIdentifier for RSASSA-PSS ---------------------------- + byte[] sigAlgId = buildPssAlgorithmIdentifier(); + + // --- BIT STRING wrapping the signature ----------------------------- + byte[] sigBitString = derBitString(sig); + + // --- Final CertificationRequest SEQUENCE --------------------------- + byte[] csr = derSequence(concat(certReqInfo, sigAlgId, sigBitString)); + + return Base64.getEncoder().encodeToString(csr); + } + + // ─── DER building blocks ────────────────────────────────────────────────── + + /** DER SEQUENCE */ + static byte[] derSequence(byte[] content) { + return derTagLen(0x30, content); + } + + /** DER SET */ + private static byte[] derSet(byte[] content) { + return derTagLen(0x31, content); + } + + /** DER INTEGER from raw bytes (big-endian, with sign byte if high bit set) */ + private static byte[] derInteger(byte[] value) { + // Add leading 0x00 if high bit is set (unsigned → signed two's complement). + byte[] content = (value[0] & 0x80) != 0 + ? concat(new byte[]{0x00}, value) + : value; + return derTagLen(0x02, content); + } + + /** DER OBJECT IDENTIFIER from pre-encoded OID value bytes */ + private static byte[] derOid(byte[] oidBytes) { + return derTagLen(0x06, oidBytes); + } + + /** DER UTF8String */ + private static byte[] derUtf8String(String s) { + byte[] bytes = s.getBytes(java.nio.charset.StandardCharsets.UTF_8); + return derTagLen(0x0C, bytes); + } + + /** DER BIT STRING — prepend 0x00 (zero unused bits) */ + static byte[] derBitString(byte[] data) { + byte[] content = new byte[data.length + 1]; + content[0] = 0x00; + System.arraycopy(data, 0, content, 1, data.length); + return derTagLen(0x03, content); + } + + /** DER NULL */ + private static final byte[] DER_NULL = {0x05, 0x00}; + + /** Context-specific explicit tag [N] wrapping content */ + private static byte[] contextExplicit(int n, byte[] content) { + return derTagLen(0xA0 | n, content); + } + + /** Context-specific implicit tag [N] wrapping content */ + private static byte[] contextImplicit(int n, byte[] content) { + return derTagLen(0x80 | n, content); + } + + /** Writes tag + DER length + content */ + private static byte[] derTagLen(int tag, byte[] content) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(tag); + int len = content.length; + if (len < 0x80) { + out.write(len); + } else if (len < 0x100) { + out.write(0x81); + out.write(len); + } else if (len < 0x10000) { + out.write(0x82); + out.write((len >> 8) & 0xFF); + out.write(len & 0xFF); + } else { + out.write(0x83); + out.write((len >> 16) & 0xFF); + out.write((len >> 8) & 0xFF); + out.write(len & 0xFF); + } + try { out.write(content); } catch (java.io.IOException ignored) {} + return out.toByteArray(); + } + + // ─── Component builders ─────────────────────────────────────────────────── + + /** + * SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING } + * AlgorithmIdentifier for RSA: SEQUENCE { OID rsaEncryption, NULL } + * Public key: BIT STRING containing RSAPublicKey SEQUENCE { modulus INTEGER, publicExp INTEGER } + */ + private static byte[] buildSpki(BigInteger modulus, int publicExp) { + // RSAPublicKey SEQUENCE { modulus INTEGER, publicExp INTEGER } + byte[] modBytes = modulus.toByteArray(); + byte[] expBytes = BigInteger.valueOf(publicExp).toByteArray(); + byte[] rsaPublicKey = derSequence(concat(derInteger(modBytes), derInteger(expBytes))); + + // AlgorithmIdentifier for rsaEncryption + byte[] algId = derSequence(concat(derOid(OID_RSA_ENCRYPTION), DER_NULL)); + + // SubjectPublicKeyInfo + return derSequence(concat(algId, derBitString(rsaPublicKey))); + } + + /** + * Name ::= SEQUENCE { RDN SEQUENCE { AttributeTypeAndValue SEQUENCE { OID, value } } } + * Subject: CN={clientId}, DC={tenantId} + * Matches msal-go: pkix.Name{CommonName: clientId, ExtraNames: []pkix.AttributeTypeAndValue{{Type: dcOID, Value: tenantId}}} + */ + private static byte[] buildSubject(String clientId, String tenantId) { + // AttributeTypeAndValue SEQUENCE { OID commonName, UTF8String clientId } + byte[] cnAttr = derSequence(concat(derOid(OID_COMMON_NAME), derUtf8String(clientId))); + byte[] cnRdn = derSet(cnAttr); + + // AttributeTypeAndValue SEQUENCE { OID domainComponent, UTF8String tenantId } + byte[] dcAttr = derSequence(concat(derOid(OID_DOMAIN_COMPONENT), derUtf8String(tenantId))); + byte[] dcRdn = derSet(dcAttr); + + // Name = SEQUENCE of RDNs + return derSequence(concat(cnRdn, dcRdn)); + } + + /** + * Builds the cuId JSON string. Matches msal-go's json.Marshal(cuID): + * {@code {"vmId":"","vmssId":""}} with omitempty semantics. + */ + private static byte[] buildCuIdJson(String vmId, String vmssId) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + if (vmId != null && !vmId.isEmpty()) { + sb.append("\"vmId\":\"").append(vmId).append("\""); + first = false; + } + if (vmssId != null && !vmssId.isEmpty()) { + if (!first) sb.append(","); + sb.append("\"vmssId\":\"").append(vmssId).append("\""); + } + sb.append("}"); + return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + /** + * CertificationRequestInfo attributes [0]: + * [0] CONSTRUCTED { + * SEQUENCE { OID 1.3.6.1.4.1.311.90.2.10, SET { UTF8String(cuIdJson) } } + * } + * + *

Per PKCS#10, {@code [0] IMPLICIT Attributes} — IMPLICIT tagging of a constructed type + * keeps the constructed bit, so the tag byte is {@code 0xA0} (context-specific, constructed). + * Mirrors msal-go's {@code buildCuIDAttribute()} which uses + * {@code asn1.RawValue{Class: ClassContextSpecific, Tag: 0, IsCompound: true}}.

+ */ + private static byte[] buildCuIdAttribute(byte[] cuIdJsonBytes) { + byte[] utf8Str = derTagLen(0x0C, cuIdJsonBytes); // UTF8String + byte[] valueSet = derSet(utf8Str); // SET { UTF8String } + byte[] attrSeq = derSequence(concat(derOid(OID_CU_ID), valueSet)); // SEQUENCE { OID, SET } + return contextExplicit(0, attrSeq); // [0] CONSTRUCTED { SEQUENCE } — 0xA0 tag + } + + /** + * AlgorithmIdentifier for RSASSA-PSS with SHA-256: + * SEQUENCE { + * OID id-RSASSA-PSS, + * SEQUENCE { -- RSASSA-PSS-params + * [0] SEQUENCE { OID sha-256, NULL }, -- hashAlgorithm + * [1] SEQUENCE { OID mgf1, SEQUENCE { OID sha-256, NULL } }, -- maskGenAlgorithm + * [2] INTEGER 32 -- saltLength + * } + * } + * Matches msal-go's explicit PSS AlgorithmIdentifier. + */ + private static byte[] buildPssAlgorithmIdentifier() { + // sha256AlgID: SEQUENCE { OID sha256, NULL } + byte[] sha256AlgId = derSequence(concat(derOid(OID_SHA256), DER_NULL)); + + // hashAlgorithm [0]: sha256AlgID + byte[] hashAlgorithm = contextExplicit(0, sha256AlgId); + + // mgf1AlgID: SEQUENCE { OID mgf1, sha256AlgID } + byte[] mgf1AlgId = derSequence(concat(derOid(OID_MGF1), sha256AlgId)); + // maskGenAlgorithm [1]: mgf1AlgID + byte[] maskGenAlgorithm = contextExplicit(1, mgf1AlgId); + + // saltLength [2]: INTEGER 32 + byte[] saltLength = contextExplicit(2, derInteger(new byte[]{32})); + + // RSASSA-PSS-params SEQUENCE + byte[] pssParams = derSequence(concat(hashAlgorithm, maskGenAlgorithm, saltLength)); + + // AlgorithmIdentifier SEQUENCE { OID id-RSASSA-PSS, pssParams } + return derSequence(concat(derOid(OID_RSASSA_PSS), pssParams)); + } + + // ─── Utility ────────────────────────────────────────────────────────────── + + private static byte[] concat(byte[]... arrays) { + int total = 0; + for (byte[] a : arrays) total += a.length; + byte[] result = new byte[total]; + int offset = 0; + for (byte[] a : arrays) { + System.arraycopy(a, 0, result, offset, a.length); + offset += a.length; + } + return result; + } + + private static byte[] hexToBytes(String hex) { + hex = hex.replace(" ", ""); + byte[] result = new byte[hex.length() / 2]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return result; + } +} diff --git a/msal4j-mtls-extensions/src/main/resources/META-INF/LICENSE-Microsoft.Azure.Security.KeyGuardAttestation.txt b/msal4j-mtls-extensions/src/main/resources/META-INF/LICENSE-Microsoft.Azure.Security.KeyGuardAttestation.txt new file mode 100644 index 00000000..88040d88 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/resources/META-INF/LICENSE-Microsoft.Azure.Security.KeyGuardAttestation.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/msal4j-mtls-extensions/src/main/resources/META-INF/NOTICE-Microsoft.Azure.Security.KeyGuardAttestation.txt b/msal4j-mtls-extensions/src/main/resources/META-INF/NOTICE-Microsoft.Azure.Security.KeyGuardAttestation.txt new file mode 100644 index 00000000..dcf47bfd --- /dev/null +++ b/msal4j-mtls-extensions/src/main/resources/META-INF/NOTICE-Microsoft.Azure.Security.KeyGuardAttestation.txt @@ -0,0 +1,5 @@ +This artifact includes AttestationClientLib.dll from +Microsoft.Azure.Security.KeyGuardAttestation 1.1.5. + +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. diff --git a/msal4j-mtls-extensions/src/main/resources/META-INF/native/win-x64/AttestationClientLib.dll b/msal4j-mtls-extensions/src/main/resources/META-INF/native/win-x64/AttestationClientLib.dll new file mode 100644 index 00000000..bb2695ef Binary files /dev/null and b/msal4j-mtls-extensions/src/main/resources/META-INF/native/win-x64/AttestationClientLib.dll differ diff --git a/msal4j-mtls-extensions/src/main/resources/META-INF/services/com.microsoft.aad.msal4j.IManagedIdentityMtlsProvider b/msal4j-mtls-extensions/src/main/resources/META-INF/services/com.microsoft.aad.msal4j.IManagedIdentityMtlsProvider new file mode 100644 index 00000000..66b24582 --- /dev/null +++ b/msal4j-mtls-extensions/src/main/resources/META-INF/services/com.microsoft.aad.msal4j.IManagedIdentityMtlsProvider @@ -0,0 +1 @@ +com.microsoft.aad.msal4j.mtls.KeyGuardManagedIdentityMtlsProvider diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AttestationLibraryLoaderTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AttestationLibraryLoaderTest.java new file mode 100644 index 00000000..b4d8169c --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AttestationLibraryLoaderTest.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.security.DigestInputStream; +import java.security.MessageDigest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class AttestationLibraryLoaderTest { + + @Test + void bundledLibraryMatchesMsalDotNetVersionAndHash() throws Exception { + assertEquals("1.1.5", AttestationLibraryLoader.VERSION); + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream resource = AttestationLibraryLoader.class.getResourceAsStream( + AttestationLibraryLoader.RESOURCE_PATH)) { + assertNotNull(resource); + try (DigestInputStream input = new DigestInputStream(resource, digest)) { + byte[] buffer = new byte[8192]; + while (input.read(buffer) != -1) { + // Consume the complete resource. + } + } + } + + assertEquals(AttestationLibraryLoader.SHA256, toHex(digest.digest())); + } + + private static String toHex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(String.format("%02x", value & 0xff)); + } + return result.toString(); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AttestationTokenCacheTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AttestationTokenCacheTest.java new file mode 100644 index 00000000..6357ab8d --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AttestationTokenCacheTest.java @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class AttestationTokenCacheTest { + + @Test + void cacheNormalizesEndpointAndIsScopedByKeyId() throws Exception { + AttestationTokenCache cache = new AttestationTokenCache(); + AtomicInteger loads = new AtomicInteger(); + + String first = cache.getOrAttest("HTTPS://Example.COM/", "key-a", + () -> token(loads.incrementAndGet(), 3600)); + String second = cache.getOrAttest("https://example.com", "key-a", + () -> token(loads.incrementAndGet(), 3600)); + String otherKey = cache.getOrAttest("https://example.com", "key-b", + () -> token(loads.incrementAndGet(), 3600)); + + assertEquals(first, second); + assertNotEquals(first, otherKey); + assertEquals(2, loads.get()); + } + + @Test + void staleTokenInsideFreshnessBufferIsNotReused() throws Exception { + AttestationTokenCache cache = new AttestationTokenCache(); + AtomicInteger loads = new AtomicInteger(); + + assertThrows(MtlsMsiException.class, + () -> cache.getOrAttest("https://example.com", "key", + () -> token(loads.incrementAndGet(), 299))); + cache.getOrAttest("https://example.com", "key", + () -> token(loads.incrementAndGet(), 3600)); + + assertEquals(2, loads.get()); + } + + @Test + void concurrentCallsAreSingleFlightPerKey() throws Exception { + AttestationTokenCache cache = new AttestationTokenCache(); + AtomicInteger loads = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + Callable task = () -> cache.getOrAttest( + "https://example.com", + "key", + () -> { + loads.incrementAndGet(); + try { + Thread.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + return token(1, 3600); + }); + List> futures = executor.invokeAll( + Collections.nCopies(8, task)); + for (Future future : futures) { + assertEquals(futures.get(0).get(), future.get()); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(1, loads.get()); + } + + @Test + void failedLoadIsNotCached() { + AttestationTokenCache cache = new AttestationTokenCache(); + AtomicInteger attempts = new AtomicInteger(); + + assertThrows(Exception.class, () -> cache.getOrAttest( + "https://example.com", "key", () -> { + attempts.incrementAndGet(); + throw new IllegalStateException("attestation failed"); + })); + assertThrows(Exception.class, () -> cache.getOrAttest( + "https://example.com", "key", () -> { + attempts.incrementAndGet(); + throw new IllegalStateException("attestation failed"); + })); + + assertEquals(2, attempts.get()); + } + + private static String token(int marker, long validForSeconds) { + String header = encode("{\"alg\":\"none\"}"); + String payload = encode("{\"marker\":" + marker + ",\"exp\":" + + (Instant.now().getEpochSecond() + validForSeconds) + "}"); + return header + "." + payload + ".signature"; + } + + private static String encode(String value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString( + value.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AuthenticodeVerifierTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AuthenticodeVerifierTest.java new file mode 100644 index 00000000..6ae8ae1c --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/AuthenticodeVerifierTest.java @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AuthenticodeVerifierTest { + + @Test + void nonzeroWinVerifyTrustResultFailsClosed() throws Exception { + AuthenticodeVerifier.WinTrustLibrary winTrust = + mock(AuthenticodeVerifier.WinTrustLibrary.class); + when(winTrust.WinVerifyTrust(any(), any(), any())) + .thenReturn(0x800B0100); + Path file = Files.createTempFile("unsigned-", ".dll"); + try { + assertThrows(MtlsMsiException.class, + () -> AuthenticodeVerifier.verify(file, winTrust)); + } finally { + Files.deleteIfExists(file); + } + } + + @Test + void successfulWinVerifyTrustResultIsAccepted() throws Exception { + AuthenticodeVerifier.WinTrustLibrary winTrust = + mock(AuthenticodeVerifier.WinTrustLibrary.class); + when(winTrust.WinVerifyTrust(any(), any(), any())) + .thenReturn(0); + Path file = Files.createTempFile("signed-", ".dll"); + try { + assertDoesNotThrow( + () -> AuthenticodeVerifier.verify(file, winTrust)); + } finally { + Files.deleteIfExists(file); + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void bundledLibraryHasValidAuthenticodeSignature() throws Exception { + Path file = Files.createTempFile("attestation-", ".dll"); + try (InputStream resource = AuthenticodeVerifierTest.class + .getResourceAsStream(AttestationLibraryLoader.RESOURCE_PATH)) { + if (resource == null) { + throw new IllegalStateException("Bundled DLL is missing."); + } + Files.copy(resource, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + assertDoesNotThrow(() -> AuthenticodeVerifier.verify(file)); + } finally { + Files.deleteIfExists(file); + } + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngKeyGuardTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngKeyGuardTest.java new file mode 100644 index 00000000..c34b7a7f --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngKeyGuardTest.java @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Pointer; +import com.sun.jna.WString; +import com.sun.jna.ptr.PointerByReference; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CngKeyGuardTest { + + @Test + void staleOpenedKeyIsDeletedSoCallerRecreatesIt() { + NCryptLibrary nativeApi = mock(NCryptLibrary.class); + Pointer provider = Pointer.createConstant(11); + Pointer staleKey = Pointer.createConstant(42); + when(nativeApi.NCryptOpenKey( + any(), + any(), + any(), + anyInt(), + anyInt())) + .thenAnswer(invocation -> { + PointerByReference reference = invocation.getArgument(1); + reference.setValue(staleKey); + return NCryptLibrary.ERROR_SUCCESS; + }); + + Pointer opened = CngKeyGuard.openExistingUsableKey( + provider, + new WString("stale-key"), + NCryptLibrary.NCRYPT_SILENT_FLAG, + nativeApi, + key -> { + // Public export may have succeeded before this private-operation failure. + throw new MtlsMsiException("NCryptSignHash failed"); + }); + + assertNull(opened); + verify(nativeApi).NCryptDeleteKey(staleKey, 0); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngProviderTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngProviderTest.java new file mode 100644 index 00000000..bb8fd990 --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngProviderTest.java @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.security.Provider; +import java.security.Security; +import java.security.Signature; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link CngProvider} — provider registration and service declarations. + * + *

Requires Windows because loading {@link CngProvider} transitively loads + * {@link CngSignatureSpi} → {@link CngRsaPrivateKey} → {@link NCryptLibrary} + * ({@code ncrypt.dll}).

+ */ +@EnabledOnOs(OS.WINDOWS) +class CngProviderTest { + + @AfterEach + void removeCngProvider() { + // Remove after each test to prevent state from bleeding between tests + Security.removeProvider("CNG"); + } + + // ─── installIfAbsent ───────────────────────────────────────────────────── + + @Test + void installIfAbsent_registersProviderByName() { + CngProvider.installIfAbsent(); + assertNotNull(Security.getProvider("CNG"), + "CNG provider must be registered in the JVM Security list after installIfAbsent()"); + } + + @Test + void installIfAbsent_isIdempotent() { + CngProvider.installIfAbsent(); + CngProvider.installIfAbsent(); // second call must be a no-op + + long cngCount = Arrays.stream(Security.getProviders()) + .filter(p -> "CNG".equals(p.getName())) + .count(); + assertEquals(1, cngCount, + "CNG provider must appear exactly once even after multiple installIfAbsent() calls"); + } + + @Test + void installIfAbsent_insertsAtHighestPriority() { + CngProvider.installIfAbsent(); + Provider[] providers = Security.getProviders(); + // Security position 1 = index 0 in the array + assertEquals("CNG", providers[0].getName(), + "CNG must be at Security position 1 (highest priority) so JSSE uses it first"); + } + + // ─── Service registrations ──────────────────────────────────────────────── + + @Test + void provider_registersSha256WithRsa() { + Provider p = new CngProvider(); + assertNotNull(p.getService("Signature", "SHA256withRSA"), + "CNG provider must advertise SHA256withRSA (used by TLS 1.2 client cert verify)"); + } + + @Test + void provider_registersSha384WithRsa() { + Provider p = new CngProvider(); + assertNotNull(p.getService("Signature", "SHA384withRSA")); + } + + @Test + void provider_registersSha512WithRsa() { + Provider p = new CngProvider(); + assertNotNull(p.getService("Signature", "SHA512withRSA")); + } + + @Test + void provider_registersRsaSsaPss() { + Provider p = new CngProvider(); + assertNotNull(p.getService("Signature", "RSASSA-PSS"), + "CNG provider must advertise RSASSA-PSS"); + } + + @Test + void provider_name_isCng() { + assertEquals("CNG", new CngProvider().getName()); + } + + @Test + void provider_sha256Alias_resolves() { + Provider p = new CngProvider(); + // Alias "SHA-256withRSA" must resolve to "SHA256withRSA" + assertNotNull(p.getService("Signature", "SHA-256withRSA"), + "Alias SHA-256withRSA must resolve via the CNG provider"); + } + + @Test + void installedProvider_bypassesOrdinaryRsaKeys() throws Exception { + CngProvider.installIfAbsent(); + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(keyPair.getPrivate()); + + assertNotEquals("CNG", signature.getProvider().getName()); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngRsaPrivateKeyTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngRsaPrivateKeyTest.java new file mode 100644 index 00000000..e66490d0 --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngRsaPrivateKeyTest.java @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Pointer; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class CngRsaPrivateKeyTest { + + @Test + void keyMaterialIsNotExportable() { + CngRsaPrivateKey key = new CngRsaPrivateKey( + Pointer.createConstant(42), + BigInteger.valueOf(17), + 65537, + () -> { }); + + assertNull(key.getEncoded()); + assertNull(key.getFormat()); + assertEquals("RSA", key.getAlgorithm()); + assertFalse(key.toString().contains("42")); + } + + @Test + void closeReleasesNativeHandleExactlyOnce() { + AtomicInteger releases = new AtomicInteger(); + CngRsaPrivateKey key = new CngRsaPrivateKey( + Pointer.createConstant(42), + BigInteger.valueOf(17), + 65537, + releases::incrementAndGet); + + key.close(); + key.close(); + + assertEquals(1, releases.get()); + assertTrue(key.isClosed()); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngSignatureParametersTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngSignatureParametersTest.java new file mode 100644 index 00000000..78e3a1e5 --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngSignatureParametersTest.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import org.junit.jupiter.api.Test; + +import java.security.InvalidAlgorithmParameterException; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; + +import static org.junit.jupiter.api.Assertions.*; + +class CngSignatureParametersTest { + + @Test + void pssAcceptsSupportedTlsParameters() throws Exception { + CngSignatureSpi spi = new CngSignatureSpi.RsaSsaPss(); + + spi.engineSetParameter(new PSSParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)); + spi.engineSetParameter(new PSSParameterSpec( + "SHA-384", "MGF1", MGF1ParameterSpec.SHA384, 48, 1)); + spi.engineSetParameter(new PSSParameterSpec( + "SHA-512", "MGF1", MGF1ParameterSpec.SHA512, 64, 1)); + } + + @Test + void pssRejectsMismatchedMgfDigest() { + CngSignatureSpi spi = new CngSignatureSpi.RsaSsaPss(); + + assertThrows(InvalidAlgorithmParameterException.class, + () -> spi.engineSetParameter(new PSSParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA384, 32, 1))); + } + + @Test + void pssRejectsUnexpectedSaltLength() { + CngSignatureSpi spi = new CngSignatureSpi.RsaSsaPss(); + + assertThrows(InvalidAlgorithmParameterException.class, + () -> spi.engineSetParameter(new PSSParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 20, 1))); + } + + @Test + void pssRejectsUnsupportedDigestInsteadOfFallingBack() { + CngSignatureSpi spi = new CngSignatureSpi.RsaSsaPss(); + + assertThrows(InvalidAlgorithmParameterException.class, + () -> spi.engineSetParameter(new PSSParameterSpec( + "SHA-1", "MGF1", MGF1ParameterSpec.SHA1, 20, 1))); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngX509ExtendedKeyManagerTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngX509ExtendedKeyManagerTest.java new file mode 100644 index 00000000..6bf99a82 --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/CngX509ExtendedKeyManagerTest.java @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Pointer; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import java.math.BigInteger; +import java.security.cert.X509Certificate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class CngX509ExtendedKeyManagerTest { + + @Test + void selectsOnlyRsaClientAliasForSocketAndEngine() throws Exception { + CngRsaPrivateKey key = new CngRsaPrivateKey( + Pointer.createConstant(42), + BigInteger.valueOf(17), + 65537, + () -> { }); + X509Certificate certificate = mock(X509Certificate.class); + CngX509ExtendedKeyManager manager = + new CngX509ExtendedKeyManager(key, certificate); + SSLEngine engine = SSLContext.getDefault().createSSLEngine(); + String alias = manager.chooseClientAlias(new String[]{"RSA"}, null, null); + + assertNotNull(alias); + assertEquals(alias, + manager.chooseEngineClientAlias(new String[]{"EC", "RSA"}, null, engine)); + assertNull(manager.chooseClientAlias(new String[]{"EC"}, null, null)); + assertNull(manager.chooseEngineClientAlias(new String[]{"EC"}, null, engine)); + assertSame(key, manager.getPrivateKey(alias)); + assertArrayEquals(new X509Certificate[]{certificate}, + manager.getCertificateChain(alias)); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/ImdsV2ClientTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/ImdsV2ClientTest.java new file mode 100644 index 00000000..5c21ef8c --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/ImdsV2ClientTest.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.microsoft.aad.msal4j.ManagedIdentityMtlsHttpResponse; +import com.microsoft.aad.msal4j.ManagedIdentityMtlsRequest; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class ImdsV2ClientTest { + + @Test + void metadataUsesCurrentV2ContractAndIdentitySelector() { + AtomicReference url = new AtomicReference<>(); + ManagedIdentityMtlsRequest request = request("client_id", "client id", http -> { + url.set(http.url()); + return response("{\"clientId\":\"client id\",\"tenantId\":\"tenant\"," + + "\"cuId\":{\"vmId\":\"vm\",\"vmssId\":\"set\"}," + + "\"attestationEndpoint\":\"https://maa.example\"}"); + }); + + ImdsV2Client.PlatformMetadata metadata = + ImdsV2Client.getPlatformMetadata(request); + + assertTrue(url.get().contains("/metadata/identity/getplatformmetadata")); + assertTrue(url.get().contains("cred-api-version=2.0")); + assertTrue(url.get().contains("client_id=client+id")); + assertEquals("client id", metadata.clientId); + assertEquals("vm", metadata.cuId()); + } + + @Test + void issueCredentialRequiresAttestationAndCurrentFields() { + ManagedIdentityMtlsRequest request = request(null, null, http -> { + assertEquals("POST", http.method()); + assertTrue(http.body().contains("\"attestation_token\":\"jwt\"")); + return response("{\"certificate\":\"cert\"," + + "\"mtls_authentication_endpoint\":\"https://login.example/token\"," + + "\"client_id\":\"client\",\"tenant_id\":\"tenant\"," + + "\"identity_type\":\"SystemAssigned\"}"); + }); + + assertThrows(MtlsMsiException.class, + () -> ImdsV2Client.issueCredential(request, "csr", "")); + ImdsV2Client.CredentialResponse credential = + ImdsV2Client.issueCredential(request, "csr", "jwt"); + + assertEquals("client", credential.clientId); + assertEquals("https://login.example/token", + credential.mtlsAuthenticationEndpoint); + } + + @Test + void issueCredentialOmitsAttestationWhenNotRequested() { + ManagedIdentityMtlsRequest request = request( + null, + null, + http -> { + assertFalse(http.body().contains("attestation_token")); + return response("{\"certificate\":\"cert\"," + + "\"mtls_authentication_endpoint\":\"https://login.example/token\"," + + "\"client_id\":\"client\",\"tenant_id\":\"tenant\"," + + "\"identity_type\":\"SystemAssigned\"}"); + }, + false); + + assertDoesNotThrow( + () -> ImdsV2Client.issueCredential(request, "csr", null)); + } + + @Test + void incompleteOrFailedImdsResponseFailsClosed() { + assertThrows(MtlsMsiException.class, + () -> ImdsV2Client.getPlatformMetadata( + request(null, null, http -> response("{}")))); + assertThrows(MtlsMsiException.class, + () -> ImdsV2Client.getPlatformMetadata( + request(null, null, http -> + new ManagedIdentityMtlsHttpResponse( + 500, "failure", Collections.emptyMap())))); + } + + @Test + void metadataRejectsResponsesWithoutImdsServerMarker() { + assertThrows(MtlsMsiException.class, + () -> ImdsV2Client.getPlatformMetadata( + request(null, null, http -> + new ManagedIdentityMtlsHttpResponse( + 200, + "{\"clientId\":\"client\",\"tenantId\":\"tenant\"," + + "\"cuId\":{\"vmId\":\"vm\"}," + + "\"attestationEndpoint\":\"https://maa.example\"}", + Collections.emptyMap())))); + } + + private static ManagedIdentityMtlsRequest request( + String selector, + String value, + com.microsoft.aad.msal4j.IManagedIdentityMtlsHttpClient client) { + return request(selector, value, client, true); + } + + private static ManagedIdentityMtlsRequest request( + String selector, + String value, + com.microsoft.aad.msal4j.IManagedIdentityMtlsHttpClient client, + boolean attestationEnabled) { + return new ManagedIdentityMtlsRequest( + selector, value, "binding", "correlation", client, + attestationEnabled); + } + + private static ManagedIdentityMtlsHttpResponse response(String body) { + Map> headers = new HashMap<>(); + headers.put("Server", Collections.singletonList("IMDS/150.0")); + return new ManagedIdentityMtlsHttpResponse( + 200, body, headers); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardManagedIdentityMtlsProviderTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardManagedIdentityMtlsProviderTest.java new file mode 100644 index 00000000..a66d4c53 --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardManagedIdentityMtlsProviderTest.java @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +class KeyGuardManagedIdentityMtlsProviderTest { + + @Test + void certificateRotatesInsideTwentyFourHourWindow() { + long now = Instant.now().toEpochMilli(); + + assertTrue(KeyGuardManagedIdentityMtlsProvider.isCertificateCurrent( + now + Duration.ofHours(25).toMillis(), now)); + assertFalse(KeyGuardManagedIdentityMtlsProvider.isCertificateCurrent( + now + Duration.ofHours(24).toMillis(), now)); + assertFalse(KeyGuardManagedIdentityMtlsProvider.isCertificateCurrent( + now + Duration.ofHours(1).toMillis(), now)); + } + + @Test + void retiredGenerationPreventsUnlockedCacheHit() { + long now = Instant.now().toEpochMilli(); + KeyGuardManagedIdentityMtlsProvider.BindingGeneration current = + new KeyGuardManagedIdentityMtlsProvider.BindingGeneration( + null, + null, + now + Duration.ofHours(25).toMillis()); + + assertTrue(KeyGuardManagedIdentityMtlsProvider + .canReturnWithoutCleanup(current, false)); + assertFalse(KeyGuardManagedIdentityMtlsProvider + .canReturnWithoutCleanup(current, true)); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContextSslTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContextSslTest.java new file mode 100644 index 00000000..38c8d941 --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContextSslTest.java @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import org.junit.jupiter.api.Test; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedKeyManager; +import java.io.InputStream; +import java.net.InetAddress; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class KeyGuardMtlsBindingContextSslTest { + + private static final char[] PASSWORD = "changeit".toCharArray(); + + @Test + void softwareKeyManagerCompletesMutualTlsHandshake() throws Exception { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (InputStream input = getClass().getResourceAsStream( + "/mtls-test-keystore.p12")) { + assertNotNull(input); + keyStore.load(input, PASSWORD); + } + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, PASSWORD); + X509ExtendedKeyManager keyManager = + extendedKeyManager(keyManagerFactory.getKeyManagers()); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + + SSLContext clientContext = KeyGuardMtlsBindingContext.createSslContext( + keyManager, + trustManagerFactory.getTrustManagers()); + SSLContext serverContext = SSLContext.getInstance("TLSv1.2"); + serverContext.init( + keyManagerFactory.getKeyManagers(), + trustManagerFactory.getTrustManagers(), + null); + + AtomicReference serverFailure = new AtomicReference<>(); + AtomicReference clientChain = new AtomicReference<>(); + + try (SSLServerSocket server = (SSLServerSocket) serverContext + .getServerSocketFactory() + .createServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + server.setNeedClientAuth(true); + server.setEnabledProtocols(new String[]{"TLSv1.2"}); + + Thread serverThread = new Thread(() -> { + try (SSLSocket socket = (SSLSocket) server.accept()) { + socket.setSoTimeout(5000); + socket.startHandshake(); + clientChain.set(socket.getSession().getPeerCertificates()); + socket.getInputStream().read(); + socket.getOutputStream().write(1); + } catch (Throwable throwable) { + serverFailure.set(throwable); + } + }); + serverThread.start(); + + try (SSLSocket client = (SSLSocket) clientContext + .getSocketFactory() + .createSocket( + InetAddress.getLoopbackAddress(), + server.getLocalPort())) { + client.setSoTimeout(5000); + client.startHandshake(); + client.getOutputStream().write(1); + client.getInputStream().read(); + assertEquals("TLSv1.2", client.getSession().getProtocol()); + } + + serverThread.join(5000); + assertFalse(serverThread.isAlive()); + } + + assertNull(serverFailure.get()); + assertNotNull(clientChain.get()); + } + + private static X509ExtendedKeyManager extendedKeyManager( + KeyManager[] keyManagers) { + for (KeyManager keyManager : keyManagers) { + if (keyManager instanceof X509ExtendedKeyManager) { + return (X509ExtendedKeyManager) keyManager; + } + } + throw new IllegalStateException("No X509ExtendedKeyManager available."); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContextTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContextTest.java new file mode 100644 index 00000000..9a01364e --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/KeyGuardMtlsBindingContextTest.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.microsoft.aad.msal4j.MtlsBindingStrength; +import com.sun.jna.Pointer; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class KeyGuardMtlsBindingContextTest { + + @Test + void keyIdUsesFullLeafCertificateDer() throws Exception { + byte[] certificateDer = new byte[]{1, 2, 3, 4, 5}; + X509Certificate certificate = mock(X509Certificate.class); + when(certificate.getEncoded()).thenReturn(certificateDer); + + KeyGuardMtlsBindingContext context = + new KeyGuardMtlsBindingContext(key(), certificate); + String expected = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(certificateDer)); + + assertEquals(expected, context.keyId()); + assertNotNull(context.sslContext()); + assertEquals("TLSv1.2", context.sslContext().getProtocol()); + assertArrayEquals( + new String[]{"TLSv1.2"}, + context.sslContext().getDefaultSSLParameters().getProtocols()); + assertNotNull(context.keyManager()); + assertSame(certificate, context.bindingCertificate()); + assertEquals(MtlsBindingStrength.KEY_GUARD, + context.bindingStrength()); + } + + @Test + void renewedCertificateWithSameKeyChangesBindingKeyId() throws Exception { + X509Certificate first = mock(X509Certificate.class); + X509Certificate second = mock(X509Certificate.class); + when(first.getEncoded()).thenReturn(new byte[]{1}); + when(second.getEncoded()).thenReturn(new byte[]{2}); + CngRsaPrivateKey key = key(); + + assertNotEquals( + new KeyGuardMtlsBindingContext(key, first).keyId(), + new KeyGuardMtlsBindingContext(key, second).keyId()); + } + + private static CngRsaPrivateKey key() { + return new CngRsaPrivateKey( + Pointer.createConstant(42), + BigInteger.valueOf(17), + 65537, + () -> { }); + } +} diff --git a/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/Pkcs10BuilderTest.java b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/Pkcs10BuilderTest.java new file mode 100644 index 00000000..0af24613 --- /dev/null +++ b/msal4j-mtls-extensions/src/test/java/com/microsoft/aad/msal4j/mtls/Pkcs10BuilderTest.java @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.mtls; + +import com.sun.jna.Pointer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.math.BigInteger; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.nullable; + +/** + * Unit tests for {@link Pkcs10Builder} DER encoding. + * + *

Tests are split into two groups:

+ *
    + *
  1. Pure DER primitives — no CNG required; run on all platforms.
  2. + *
  3. Full CSR generation — requires Windows (loads NCryptLibrary via + * CngKeyGuard); {@link CngKeyGuard#signPss} is mocked so no real CNG key is needed.
  4. + *
+ * + *

The CSR format must match msal-go's {@code generateCSR()} and MSAL.NET's + * {@code Csr.Generate()} exactly so that the Azure IMDS {@code /issuecredential} + * endpoint can parse and validate it.

+ */ +class Pkcs10BuilderTest { + + // ─── DER primitives (pure Java, cross-platform) ─────────────────────────── + + @Test + void derSequence_short_wrapsWithTag30() { + byte[] content = {0x01, 0x02, 0x03}; + byte[] seq = Pkcs10Builder.derSequence(content); + + assertEquals(0x30, seq[0] & 0xFF, "SEQUENCE tag must be 0x30"); + assertEquals(3, seq[1] & 0xFF, "Short-form length must equal content length"); + assertEquals(0x01, seq[2]); + assertEquals(0x02, seq[3]); + assertEquals(0x03, seq[4]); + assertEquals(5, seq.length); + } + + @Test + void derSequence_shortFormMaxLength() { + // 127 bytes is the maximum for single-byte short-form length + byte[] content = new byte[127]; + byte[] seq = Pkcs10Builder.derSequence(content); + + assertEquals(0x30, seq[0] & 0xFF); + assertEquals(127, seq[1] & 0xFF); + assertEquals(2 + 127, seq.length); + } + + @Test + void derSequence_longForm1Byte_length128() { + // 128 bytes requires 0x81 long-form header + byte[] content = new byte[128]; + byte[] seq = Pkcs10Builder.derSequence(content); + + assertEquals(0x30, seq[0] & 0xFF); + assertEquals(0x81, seq[1] & 0xFF, "Long-form header byte for lengths 128-255 must be 0x81"); + assertEquals(128, seq[2] & 0xFF); + assertEquals(3 + 128, seq.length); + } + + @Test + void derSequence_longForm2Byte_length256() { + // 256 bytes requires 0x82 two-byte length + byte[] content = new byte[256]; + byte[] seq = Pkcs10Builder.derSequence(content); + + assertEquals(0x30, seq[0] & 0xFF); + assertEquals(0x82, seq[1] & 0xFF, "Long-form header for lengths 256+ must be 0x82"); + assertEquals(1, seq[2] & 0xFF, "High byte of length 256 (0x0100)"); + assertEquals(0, seq[3] & 0xFF, "Low byte of length 256"); + assertEquals(4 + 256, seq.length); + } + + @Test + void derBitString_prependsZeroUnusedBitsByte() { + byte[] data = {0x01, 0x02}; + byte[] bs = Pkcs10Builder.derBitString(data); + + assertEquals(0x03, bs[0] & 0xFF, "BIT STRING tag must be 0x03"); + assertEquals(3, bs[1] & 0xFF, "Length must cover the unused-bits byte + data"); + assertEquals(0x00, bs[2], "Unused bits must be 0x00 (byte-aligned content)"); + assertEquals(0x01, bs[3]); + assertEquals(0x02, bs[4]); + } + + @Test + void derBitString_empty_hasOnlyUnusedBitsByte() { + byte[] bs = Pkcs10Builder.derBitString(new byte[0]); + assertEquals(0x03, bs[0] & 0xFF); + assertEquals(1, bs[1] & 0xFF); + assertEquals(0x00, bs[2]); + } + + // ─── Full CSR generation (Windows only — CngKeyGuard is mocked) ─────────── + + @Test + @EnabledOnOs(OS.WINDOWS) + void generate_outputIsBase64EncodedDerSequence() throws Exception { + BigInteger modulus = BigInteger.valueOf(2).pow(2047).add(BigInteger.ONE); + byte[] fakeSignature = new byte[256]; // 2048-bit RSA output size + + try (MockedStatic mockCng = Mockito.mockStatic(CngKeyGuard.class)) { + mockCng.when(() -> CngKeyGuard.signPss(any(), any(), anyString(), anyInt())) + .thenReturn(fakeSignature); + + String b64 = Pkcs10Builder.generate( + Pointer.NULL, modulus, 65537, + "test-client-id", "test-tenant-id", "vm-id-1", null); + + assertNotNull(b64); + byte[] der = Base64.getDecoder().decode(b64); + assertEquals(0x30, der[0] & 0xFF, + "Outermost CSR element must be a DER SEQUENCE (0x30)"); + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void generate_cuIdAttributeContainsVmIdAndVmssId() throws Exception { + BigInteger modulus = BigInteger.valueOf(2).pow(2047).add(BigInteger.ONE); + byte[] fakeSignature = new byte[256]; + + try (MockedStatic mockCng = Mockito.mockStatic(CngKeyGuard.class)) { + mockCng.when(() -> CngKeyGuard.signPss(any(), any(), anyString(), anyInt())) + .thenReturn(fakeSignature); + + String b64 = Pkcs10Builder.generate( + Pointer.NULL, modulus, 65537, + "client-a", "tenant-b", "my-vm-id", "my-vmss-id"); + + byte[] der = Base64.getDecoder().decode(b64); + String derText = new String(der, java.nio.charset.StandardCharsets.UTF_8); + assertTrue(derText.contains("my-vm-id"), + "CSR DER must embed vmId in the cuId attribute"); + assertTrue(derText.contains("my-vmss-id"), + "CSR DER must embed vmssId in the cuId attribute"); + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void generate_cuIdAttributeEmptyObject_whenBothIdsNull() throws Exception { + BigInteger modulus = BigInteger.valueOf(2).pow(2047).add(BigInteger.ONE); + byte[] fakeSignature = new byte[256]; + + try (MockedStatic mockCng = Mockito.mockStatic(CngKeyGuard.class)) { + mockCng.when(() -> CngKeyGuard.signPss(any(), any(), anyString(), anyInt())) + .thenReturn(fakeSignature); + + String b64 = Pkcs10Builder.generate( + Pointer.NULL, modulus, 65537, + "client-a", "tenant-b", null, null); + + byte[] der = Base64.getDecoder().decode(b64); + String derText = new String(der, java.nio.charset.StandardCharsets.UTF_8); + assertTrue(derText.contains("{}"), + "cuId JSON must be '{}' when both vmId and vmssId are null (omitempty)"); + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void generate_subjectContainsClientIdAndTenantId() throws Exception { + BigInteger modulus = BigInteger.valueOf(2).pow(2047).add(BigInteger.ONE); + byte[] fakeSignature = new byte[256]; + + try (MockedStatic mockCng = Mockito.mockStatic(CngKeyGuard.class)) { + mockCng.when(() -> CngKeyGuard.signPss(any(), any(), anyString(), anyInt())) + .thenReturn(fakeSignature); + + String b64 = Pkcs10Builder.generate( + Pointer.NULL, modulus, 65537, + "subject-client-id", "subject-tenant-id", null, null); + + byte[] der = Base64.getDecoder().decode(b64); + String derText = new String(der, java.nio.charset.StandardCharsets.UTF_8); + assertTrue(derText.contains("subject-client-id"), + "CSR subject (CN) must contain the clientId"); + assertTrue(derText.contains("subject-tenant-id"), + "CSR subject (DC) must contain the tenantId"); + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void generate_signPssCalledWithSha256AndSalt32() throws Exception { + BigInteger modulus = BigInteger.valueOf(2).pow(2047).add(BigInteger.ONE); + byte[] fakeSignature = new byte[256]; + + try (MockedStatic mockCng = Mockito.mockStatic(CngKeyGuard.class)) { + mockCng.when(() -> CngKeyGuard.signPss(any(), any(), anyString(), anyInt())) + .thenReturn(fakeSignature); + + Pkcs10Builder.generate( + Pointer.NULL, modulus, 65537, + "c", "t", null, null); + + // Verify the exact signature algorithm parameters (must match msal-go and MSAL.NET) + mockCng.verify(() -> CngKeyGuard.signPss( + nullable(Pointer.class), + any(byte[].class), + eq("SHA256"), + eq(32))); + } + } +} diff --git a/msal4j-mtls-extensions/src/test/resources/mtls-test-keystore.p12 b/msal4j-mtls-extensions/src/test/resources/mtls-test-keystore.p12 new file mode 100644 index 00000000..ee101d83 Binary files /dev/null and b/msal4j-mtls-extensions/src/test/resources/mtls-test-keystore.p12 differ diff --git a/msal4j-sdk/docs/managed-identity-v2-mtls-pop-review-guide.md b/msal4j-sdk/docs/managed-identity-v2-mtls-pop-review-guide.md new file mode 100644 index 00000000..f2248803 --- /dev/null +++ b/msal4j-sdk/docs/managed-identity-v2-mtls-pop-review-guide.md @@ -0,0 +1,785 @@ +# Reviewer guide: Managed Identity v2 KeyGuard mTLS PoP + +This guide is the recommended entry point for reviewing the Managed Identity v2 +mTLS Proof-of-Possession change. It explains the intended architecture, security +boundaries, protocol flow, review order, test coverage, and manual validation. + +The implementation is intentionally split between the portable MSAL core and an +optional Windows extension. Reviewers should verify that this separation remains +intact: native code performs only platform cryptographic operations, while Java +continues to own OAuth, HTTP, caching, certificate parsing, and TLS. + +## Review goals + +The change is successful only if all of the following remain true: + +- The KeyGuard private key is never exported into Java memory. +- Java JSSE performs both token-endpoint and downstream TLS. +- Native interop is limited to Windows CNG/KeyGuard and attestation operations. +- Attestation is optional, but fails closed whenever it is requested. +- Tokens are cached only with the exact certificate binding that produced them. +- A normal bearer token cannot satisfy an mTLS PoP request. +- A token bound to certificate A cannot be used with certificate B. +- Application developers receive a reusable standard Java `SSLContext`. +- The standard MSAL OAuth pipeline remains responsible for token requests. +- Custom HTTP clients cannot silently discard the mTLS configuration. +- Credential-bound HTTP requests cannot follow redirects. +- The optional native extension does not affect applications that do not use it. +- Java 8 source and bytecode compatibility are preserved. + +## Recommended review order + +Reviewing the files in this order minimizes context switching: + +1. Public API and result surface. +2. Managed Identity request orchestration. +3. OAuth and HTTP integration. +4. Token-cache partitioning. +5. Optional provider loading. +6. KeyGuard and signing bridge. +7. Certificate and binding lifecycle. +8. Attestation and IMDS v2. +9. Native packaging. +10. Unit tests and the manual E2E. + +## Architecture summary + +```mermaid +flowchart TD + App[Application] --> MIA[ManagedIdentityApplication] + MIA --> Core[MSAL core] + Core --> SPI[IManagedIdentityMtlsProvider] + SPI --> Ext[Optional Windows extension] + Ext --> IMDS[IMDS v2] + Ext --> KG[Windows CNG / KeyGuard] + Ext --> ATT[AttestationClientLib] + KG --> Key[Non-exportable RSA key] + ATT --> MAA[MAA attestation JWT] + IMDS --> Cert[Binding certificate] + Ext --> Context[IMtlsBindingContext] + Context --> JSSE[Java SSLContext / JSSE] + Core --> ESTS[Normal MSAL OAuth pipeline] + JSSE --> ESTS + ESTS --> Result[mtls_pop token + binding context] + Result --> Resource[Independent Java HTTPS resource call] +``` + +### Ownership boundary + +| Component | Owns | Must not own | +| --- | --- | --- | +| MSAL core | OAuth, claims, capabilities, retries, telemetry, response parsing, token cache | KeyGuard handles, CSR construction, attestation implementation | +| Windows extension | CNG key operations, attestation, CSR, binding certificate lifecycle | Bespoke OAuth token client, persistent token cache | +| JCA/JSSE bridge | TLS signatures through `PrivateKey` and `SignatureSpi` | Private-key export | +| Application | Independent downstream HTTP call through returned `SSLContext` | Native key-handle management | + +## End-to-end protocol flow + +```mermaid +sequenceDiagram + participant App as Application + participant Core as MSAL core + participant Ext as KeyGuard extension + participant IMDS as IMDS v2 + participant MAA as Attestation library / MAA + participant ESTS as ESTS token endpoint + participant KV as Token-bound resource + + App->>Core: acquireTokenForManagedIdentity(parameters) + Core->>Ext: acquireBinding(request, HTTP callback) + Ext->>IMDS: GET getPlatformMetadata + IMDS-->>Ext: identity, CUID, regional token URL, attestation endpoint + Ext->>Ext: open or create KeyGuard key + Ext->>Ext: private signing liveness probe + opt Attestation requested + Ext->>MAA: attest KeyGuard handle + MAA-->>Ext: attestation JWT + end + Ext->>Ext: build RSA-PSS PKCS#10 CSR + Ext->>IMDS: POST issuecredential + IMDS-->>Ext: binding certificate + Ext->>Ext: verify certificate public key + Ext-->>Core: certificate + binding SSLContext + key ID + Core->>Core: binding-aware token-cache lookup + alt Cache miss + Core->>ESTS: normal OAuth request over binding SSLContext + ESTS-->>Core: token_type=mtls_pop + access token + Core->>Core: validate token type + Core->>Core: binding-aware cache write + end + Core-->>App: token + IMtlsBindingContext + App->>KV: Java HTTPS with token and returned SSLContext + KV-->>App: protected resource response +``` + +## Public API review + +### Parameter combinations + +The intended combinations are: + +```java +ManagedIdentityParameters.builder(resource) + .withMtlsProofOfPossession() + .build(); +``` + +```java +ManagedIdentityParameters.builder(resource) + .withMtlsProofOfPossession() + .withAttestationSupport() + .build(); +``` + +The following must be rejected: + +```java +ManagedIdentityParameters.builder(resource) + .withAttestationSupport() + .build(); +``` + +Attestation is a strengthening option for an mTLS binding. It is not a +standalone token-acquisition mode. + +### Result surface + +An mTLS result exposes: + +- `tokenType()`, which must be exactly `mtls_pop`; +- `bindingCertificate()`, the public leaf certificate; +- `mtlsBindingContext()`, a process-local reusable binding context; +- `mtlsBindingContext().sslContext()`, the standard JSSE context; +- `mtlsBindingContext().keyId()`, the full-certificate binding identity. + +The binding context is intentionally not serializable. Native handles and +`SSLContext` instances must be reconstructed by each process. + +## Core request orchestration + +Primary file: + +`msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplier.java` + +Review these invariants: + +- The extension is invoked only for mTLS PoP requests. +- Provider exceptions are normalized to MSAL exceptions. +- Existing MSAL exceptions retain their original error codes. +- IMDS calls use request-scoped IMDS retry behavior. +- The external ESTS request does not inherit IMDS retry behavior. +- The resolved binding cache key is stored on request-scoped state. +- Public `ManagedIdentityParameters` are not mutated during acquisition. +- Cache lookup and cache write use the same resolved extended cache hash. +- The final result preserves token metadata when the binding context is attached. + +## OAuth pipeline integration + +Primary files: + +- `TokenRequestExecutor.java` +- `OAuthHttpRequest.java` +- `HttpRequest.java` +- `DefaultHttpClient.java` +- `TokenResponse.java` + +The ESTS exchange must remain a specialization of the normal OAuth pipeline, +not a second token client. + +Review that the request-specific endpoint and socket factory do not bypass: + +- claims merging; +- client capabilities; +- telemetry headers; +- correlation IDs; +- retry and error parsing; +- token response deserialization; +- refresh metadata; +- cache writes. + +The token response must explicitly contain `token_type=mtls_pop`. Missing or +different token types fail closed. + +## HTTP security review + +### Redirects + +Any request carrying a request-specific client-certificate socket factory must +disable automatic redirects. A redirect could disclose proof-of-possession to +an unintended destination. + +### HTTPS-only endpoint + +The regional token endpoint returned by platform metadata must use HTTPS. +Reject non-HTTPS endpoints before sending OAuth parameters or presenting the +binding certificate. + +### Custom HTTP clients + +MSAL Java supports application-provided `IHttpClient` implementations. Existing +implementations predate request-specific socket factories. + +The `IMtlsCapableHttpClient` marker is an explicit compatibility contract: + +- the client understands `HttpRequest.sslSocketFactory()`; +- the client applies that factory to the exact request; +- the client preserves no-redirect behavior; +- a custom client lacking the capability fails fast. + +Silent fallback to a non-mTLS request is a security failure. + +## Binding-aware token cache + +An mTLS token is reusable only with the certificate to which it was issued. + +The extended cache identity includes: + +| Dimension | Value | +| --- | --- | +| Token type | `mtls_pop` | +| Binding identity | Base64UrlNoPadding(SHA-256(full leaf certificate DER)) | +| Attestation mode | Attested and unattested requests are isolated | +| Existing MSAL dimensions | Authority, tenant, client, scopes/resource, claims, account, flow | + +Review these cases: + +- bearer cache entries cannot satisfy mTLS requests; +- certificate A cannot satisfy certificate B; +- same-key certificate renewal changes the cache partition; +- attested and unattested bindings never cross-hit; +- force refresh bypasses the access-token cache; +- cache hits still return a live binding context. + +## KeyGuard private-key bridge + +```mermaid +flowchart LR + JSSE[JSSE handshake] --> Signature[Signature API] + Signature --> SPI[CngSignatureSpi] + SPI --> JNA[JNA] + JNA --> NCrypt[NCryptSignHash] + NCrypt --> KG[VBS KeyGuard] + KG --> Signature +``` + +### `CngRsaPrivateKey` + +Review that: + +- `getEncoded()` returns `null`; +- `getFormat()` returns `null`; +- private exponent access is unavailable; +- only public modulus and exponent are represented in Java; +- native handle cleanup is idempotent; +- accidental Java serialization cannot expose private key material. + +### `CngSignatureSpi` + +Review that: + +- supported hashes are explicit; +- unsupported algorithms fail rather than defaulting; +- PSS parameters are validated; +- MGF must be MGF1; +- digest and MGF digest must match; +- salt lengths must be supported; +- trailer field must be one; +- non-KeyGuard keys delegate to another provider; +- provider delegation cannot recurse into `CngProvider`; +- signatures are produced only through `NCryptSignHash`. + +### `CngX509ExtendedKeyManager` + +Review socket and engine paths: + +- `chooseClientAlias`; +- `chooseEngineClientAlias`; +- certificate chain lookup; +- private key lookup; +- RSA key-type filtering. + +Both `SSLSocket` and `SSLEngine` consumers must be supported. + +## Key lifecycle + +### Per-boot stale keys + +KeyGuard KSP metadata can survive a reboot even when the VBS-protected private +material is no longer usable. + +Opening the key and exporting its public key is not a sufficient liveness test. +The extension performs a private signing probe after reopening an existing key. + +```mermaid +flowchart TD + Open[Open persisted key] --> Export[Export public key] + Export --> Probe[Private signing probe] + Probe -->|Success| Use[Use existing key] + Probe -->|Failure| Delete[Delete stale key] + Delete --> Create[Create new KeyGuard key] + Create --> Attest[Create new attestation and certificate] +``` + +Review that stale-key recovery: + +- deletes the unusable key; +- recreates it before CSR generation; +- does not reuse attestation evidence for old key material; +- closes failed native handles; +- fails closed if recreation is unsuccessful. + +### Certificate rotation + +Review that: + +- certificates rotate before expiry; +- the current generation remains available during safe handoff; +- old generations are retained only while needed; +- expired retired generations close native handles; +- normal cache hits still perform retired-generation cleanup; +- rotation changes the token-cache binding partition. + +## Attestation + +Attestation is requested only when `.withAttestationSupport()` is present. + +When selected: + +- missing DLL loading fails; +- empty attestation output fails; +- malformed JWTs fail; +- expired evidence fails; +- stale evidence inside the freshness buffer is not reused; +- failures never downgrade to unattested issuance. + +### Attestation cache + +The cache identity is: + +```text +normalized attestation endpoint + fingerprint of current public key material +``` + +Review: + +- normalized endpoint handling; +- five-minute freshness buffer; +- successful-result-only caching; +- no caching of failures; +- per-key single-flight synchronization; +- no coalescing between distinct keys. + +## IMDS v2 review + +Primary file: + +`msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/ImdsV2Client.java` + +Protocol values: + +| Area | Value | +| --- | --- | +| API query | `cred-api-version=2.0` | +| Metadata path | `/metadata/identity/getplatformmetadata` | +| Credential path | `/metadata/identity/issuecredential` | +| Identity types | `SystemAssigned`, `UserAssigned` | +| CUID CSR OID | `1.3.6.1.4.1.311.90.2.10` | +| CSR signature | RSA-PSS SHA-256 | + +Review that: + +- requests include the IMDS metadata header; +- metadata response includes the expected IMDS server marker; +- identity selection matches the requested managed identity; +- required fields are validated before use; +- optional attestation is omitted when not requested; +- missing attestation fails only when attestation was requested; +- certificate response is validated before constructing the binding context. + +## CSR review + +Primary file: + +`msal4j-mtls-extensions/src/main/java/com/microsoft/aad/msal4j/mtls/Pkcs10Builder.java` + +Review: + +- DER encoding, not text concatenation; +- subject and public-key encoding; +- CUID attribute OID; +- UTF-8 JSON attribute value; +- RSA-PSS SHA-256 signature; +- correct MGF1 parameters; +- salt length; +- signature BIT STRING encoding; +- CSR uses the current KeyGuard key. + +## Native DLL packaging + +The optional extension bundles the x64 native DLL from: + +```text +Microsoft.Azure.Security.KeyGuardAttestation 1.1.5 +``` + +This matches the version pinned by current MSAL.NET. + +The DLL is stored at: + +```text +META-INF/native/win-x64/AttestationClientLib.dll +``` + +Expected SHA-256: + +```text +90dfcce20e1a74519b49796eeee17e6e59a257c3acf754f454a49380d28a568b +``` + +Review: + +- resource exists in the production extension JAR; +- resource also survives E2E shading; +- runtime extraction uses a unique temporary directory; +- extracted bytes are verified before loading; +- architecture mismatch fails clearly; +- manual `PATH` or `java.library.path` configuration is unnecessary; +- package license and notice are included; +- only the optional extension carries the native payload. + +## Test inventory + +### Core SDK + +| Test area | Purpose | +| --- | --- | +| Parameter tests | Valid and invalid PoP/attestation combinations | +| Provider loader tests | Missing, unique, and ambiguous providers | +| Binding tests | Endpoint and certificate-binding validation | +| Result tests | Token type and transient binding context | +| HTTP tests | Socket factory application and redirect prevention | +| Token executor tests | Normal OAuth pipeline with request-specific TLS | +| Managed Identity supplier tests | Exception normalization and IMDS retry behavior | +| Cache tests | Binding-aware extended cache partitions | + +### Windows extension + +| Test area | Purpose | +| --- | --- | +| KeyGuard tests | Stale-key detection and recreation | +| Private-key tests | Non-exportability and cleanup | +| Provider tests | Signature registrations and delegation | +| Signature tests | PKCS#1 and PSS parameter validation | +| Key-manager tests | Socket and engine alias selection | +| CSR tests | DER structure and PSS signing | +| Attestation cache tests | Expiry, freshness, and single flight | +| IMDS tests | Contracts, optional attestation, origin validation | +| Binding-context tests | Full-DER key ID and configured SSL context | +| Provider lifecycle tests | Rotation and retired-generation cleanup | +| Native-loader tests | Version and packaged DLL hash | + +## Build commands + +From the repository root: + +```powershell +mvn -pl msal4j-mtls-extensions -am test +``` + +Build the profile-only E2E: + +```powershell +mvn -pl msal4j-mtls-extensions-e2e -am ` + -Pe2e ` + -DskipTests ` + -Dmaven.javadoc.skip=true ` + package +``` + +The production extension JAR must not contain the E2E main class. + +The shaded E2E artifact is: + +```text +msal4j-mtls-extensions-e2e/target/*-e2e.jar +``` + +## Manual VM prerequisites + +Use a disposable Windows x64 Trusted Launch Azure VM or VMSS instance with: + +- Secure Boot; +- vTPM; +- VBS / Credential Guard; +- Managed Identity; +- Java 8 or later; +- network access to IMDS, attestation, ESTS, and the test resource. + +For attestation, the TPM must report that it is capable of attestation. + +Do not use production secrets for the manual test. + +## Key Vault test configuration + +Use a dedicated test vault and secret. + +The VM managed identity needs only secret `get` permission. + +For an access-policy vault: + +```powershell +az keyvault set-policy ` + --resource-group ` + --name ` + --object-id ` + --secret-permissions get +``` + +The test vault must be configured for token-bound authentication. Apply service +configuration only to a dedicated vault because enforcement can reject ordinary +clients. + +Example ARM body: + +```json +{ + "properties": { + "tokenBindingParameters": { + "mode": "Enforced", + "minimumTokenBindingStrength": "Unattested" + } + } +} +``` + +Reviewers should use the currently supported Key Vault ARM API version available +in their test environment. + +## Run the E2E + +Set: + +```powershell +$env:MSAL_JAVA_MTLS_AKV_URL = "https://.vault.azure.net" +$env:MSAL_JAVA_MTLS_AKV_SECRET_NAME = "" +``` + +Optional identity selection: + +```powershell +$env:MSAL_JAVA_MTLS_IDENTITY_CLIENT_ID = "" +``` + +Optional force refresh: + +```powershell +$env:MSAL_JAVA_MTLS_FORCE_REFRESH = "true" +``` + +Optional second identity for token-A/binding-B rejection: + +```powershell +$env:MSAL_JAVA_MTLS_MISMATCH_IDENTITY_CLIENT_ID = "" +``` + +Run: + +```powershell +.\run-java-msi-v2-mtls-devapp.ps1 +``` + +No standalone attestation DLL or native library path is required. + +## Expected positive output + +The app must confirm: + +```text +PASS: token_type = mtls_pop +PASS: binding certificate returned +PASS: reusable JSSE binding context returned +PASS: cnf.x5t#S256 matches binding certificate +PASS: HTTP 200 +PASS: AKV response validated +PASS: TokenSource = CACHE +PASS: matching binding context available +RESULT: PASS +``` + +## Expected negative output: no certificate + +The app reuses the same valid `mtls_pop` token but creates a client connection +without the binding key manager. + +Expected result: + +```text +PASS: token without certificate rejected with HTTP 401 Unauthorized +``` + +This proves the resource is not accepting the token merely because it is +otherwise valid. + +## Expected negative output: mismatched certificate + +When a distinct second UAMI is configured: + +```text +token A + binding A -> HTTP 200 +token A + binding B -> rejected +``` + +The app verifies that the two key IDs differ before making the negative call. + +## Live validation completed + +The current implementation has been validated on a Windows Server 2025 Trusted +Launch Azure VM: + +- the extension JAR loaded the bundled attestation DLL; +- no standalone DLL was placed beside the app; +- attestation completed; +- ESTS returned `mtls_pop`; +- token `cnf.x5t#S256` matched the full certificate DER hash; +- the returned Java `SSLContext` performed the downstream request; +- the test Key Vault returned HTTP 200 with the correct certificate; +- the same valid token without the certificate returned HTTP 401; +- the second acquisition returned `TokenSource.CACHE`. + +Environment-specific subscription, tenant, identity, vault, and secret +identifiers are intentionally omitted. + +## Troubleshooting + +| Symptom | Likely cause | Check | +| --- | --- | --- | +| Extension provider not found | Extension JAR absent | Application dependencies and ServiceLoader resource | +| Multiple providers found | Duplicate extension implementations | Classpath | +| KeyGuard unavailable | VBS or Trusted Launch missing | Secure Boot, vTPM, VBS status | +| Stale key after reboot | Per-boot private material lost | Signing liveness probe and recreation logs | +| Attestation DLL load failure | Corrupt or wrong architecture resource | JAR resource, hash, Windows x64 | +| Attestation failure | TPM not provisioned | TPM attestation capability | +| IMDS metadata rejected | Missing IMDS response marker | Response headers and endpoint | +| Credential issuance rejected | CSR, CUID, identity, or attestation mismatch | IMDS response body and correlation ID | +| ESTS token type is not `mtls_pop` | Service not enrolled or request invalid | Token response and endpoint | +| `cnf` mismatch | Wrong certificate or cache partition | Full-DER key ID | +| Resource HTTP 401 without certificate | Expected negative result | Confirm positive call still returns 200 | +| Resource rejects correct certificate | Resource enrollment or identity permission | Resource configuration and access policy | +| Custom HTTP client failure | Client does not honor socket factory | `IMtlsCapableHttpClient` implementation | +| Unexpected redirect | Credential endpoint redirected | Redirect policy and configured endpoint | +| Second acquisition hits IDP | Cache identity changed or force refresh enabled | Token source and key ID | + +## Threat-model checklist + +### Private key + +- [ ] No Java API exposes private key bytes. +- [ ] No export flags permit private-key export. +- [ ] Every TLS signature reaches `NCryptSignHash`. +- [ ] Handles are closed exactly once. +- [ ] Stale handles are deleted and recreated. + +### Attestation + +- [ ] Optional unless explicitly requested. +- [ ] Fail closed when requested. +- [ ] Cache is key-bound and endpoint-bound. +- [ ] Failures are not cached. +- [ ] Expiry and freshness buffer are enforced. + +### Certificate + +- [ ] Issued certificate public key matches KeyGuard key. +- [ ] Full DER determines binding identity. +- [ ] Rotation creates a new cache partition. +- [ ] Old native handles are retired and closed. + +### OAuth + +- [ ] Normal token pipeline is used. +- [ ] Claims and capabilities are preserved. +- [ ] Token endpoint is HTTPS. +- [ ] Token type is explicitly validated. +- [ ] Provider errors become MSAL errors. + +### HTTP + +- [ ] Request-specific socket factory is applied. +- [ ] Redirects are disabled. +- [ ] Custom clients fail fast without mTLS capability. +- [ ] Downstream calls can use standard Java clients. + +### Cache + +- [ ] Bearer and mTLS entries cannot cross-hit. +- [ ] Certificate A and B cannot cross-hit. +- [ ] Attested and unattested entries cannot cross-hit. +- [ ] Lookup and write use the same request-scoped key. + +### Packaging + +- [ ] Native DLL version is recorded. +- [ ] Native DLL hash is tested. +- [ ] License and notice are included. +- [ ] E2E code is absent from the production JAR. +- [ ] No manual DLL deployment is required. + +## File-focused checklist + +### Core API + +- [ ] `ManagedIdentityParameters` validates option combinations. +- [ ] `IAuthenticationResult` exposes binding metadata without breaking old callers. +- [ ] `AuthenticationResult` preserves existing metadata. +- [ ] New interfaces are minimal and documented. + +### Supplier + +- [ ] Binding acquisition happens before mTLS cache lookup. +- [ ] Extended cache hash is immutable request state. +- [ ] IMDS and ESTS retry policies remain separated. +- [ ] Errors do not leak extension implementation types. + +### HTTP stack + +- [ ] Socket factory remains request-scoped. +- [ ] Default HTTP client applies it only to the intended request. +- [ ] No redirects occur for credential-bound traffic. + +### Extension + +- [ ] Provider lifecycle is concurrency-safe. +- [ ] Attestation cache is concurrency-safe. +- [ ] Native loader is concurrency-safe. +- [ ] Certificate rotation is concurrency-safe. +- [ ] Failure paths close handles. + +### E2E + +- [ ] Uses `IAuthenticationResult` and `IMtlsBindingContext`. +- [ ] Does not call an MSAL downstream-resource helper. +- [ ] Verifies `cnf.x5t#S256`. +- [ ] Requires exact HTTP 200 for the positive resource call. +- [ ] Requires HTTP 401 when no certificate is presented. +- [ ] Verifies cache reuse. +- [ ] Supports force refresh. +- [ ] Supports token-A/binding-B rejection with a second identity. + +## Review completion criteria + +The PR is ready only when reviewers can answer yes to each question: + +1. Is private key material always non-exportable? +2. Does JSSE perform TLS without WinHTTP or Schannel as the Java HTTP stack? +3. Does the standard OAuth token pipeline remain intact? +4. Are token cache entries bound to the complete certificate identity? +5. Does attestation fail closed when requested? +6. Are stale per-boot KeyGuard keys recovered safely? +7. Are credential-bound redirects prevented? +8. Can custom HTTP clients fail safely? +9. Is the native DLL packaged, verified, and licensed? +10. Does the positive Key Vault call return HTTP 200? +11. Does the same token without the certificate return HTTP 401? +12. Does a cache hit retain the correct live binding context? +13. Are tests and production code Java 8 compatible? +14. Is the branch still a single coherent commit? diff --git a/msal4j-sdk/docs/managed-identity-v2-mtls-pop.md b/msal4j-sdk/docs/managed-identity-v2-mtls-pop.md new file mode 100644 index 00000000..1feca055 --- /dev/null +++ b/msal4j-sdk/docs/managed-identity-v2-mtls-pop.md @@ -0,0 +1,205 @@ +# Managed Identity v2 attested mTLS PoP + +## Architecture + +MSAL core owns request validation, identity selection, correlation IDs, HTTP +policy, proxy behavior, retries, telemetry, OAuth form construction, response +parsing, and token caching. The optional Windows extension owns only: + +1. KeyGuard RSA key creation and NCrypt signing. +2. CSR construction. +3. `AttestationClientLib.dll` invocation and MAA JWT caching. +4. IMDS v2 binding-certificate lifecycle. +5. A JCA `PrivateKey`, key-selective `Provider`, `SignatureSpi`, and + `X509ExtendedKeyManager`. + +JSSE performs the token-endpoint and downstream mTLS handshakes: + +```text +JSSE + -> X509ExtendedKeyManager + -> CngRsaPrivateKey + -> CngSignatureSpi + -> NCryptSignHash + -> VBS KeyGuard +``` + +The provider advertises the required RSA signature algorithms, but its services +accept only `CngRsaPrivateKey`. Ordinary Java RSA keys continue to use the +platform's normal providers. + +The current flow uses TLS 1.2 because the service does not yet request the +required client certificate during TLS 1.3 negotiation. TLS 1.3 support is +being investigated with the service team and can be enabled after the +end-to-end client-certificate behavior is supported and validated. + +## Current protocol contract + +Current MSAL.NET product behavior takes precedence over older design and +prototype material where they differ: + +| Area | Current behavior | +| --- | --- | +| IMDS API | `cred-api-version=2.0` | +| Metadata path | `/metadata/identity/getplatformmetadata` | +| Credential path | `/metadata/identity/issuecredential` | +| Metadata fields | `clientId`, `tenantId`, `cuId`, `attestationEndpoint` | +| Credential fields | `certificate`, `client_id`, `tenant_id`, `identity_type`, `mtls_authentication_endpoint` | +| CSR subject | `CN={clientId}, DC={tenantId}` | +| CUID attribute | OID `1.3.6.1.4.1.311.90.2.10`, DER UTF8 JSON | +| CSR signature | RSA-PSS with SHA-256 and 32-byte salt | +| Certificate rotation | 24 hours before expiry | +| Token request | client credentials with `token_type=mtls_pop` over JSSE mTLS | + +The older design's `api-version=2025-05-01`, challenge-password CUID encoding, +and three-day rotation window are not used. + +## Public API and Java 8 compatibility + +Request mTLS PoP and opt into attestation separately: + +```java +ManagedIdentityParameters.builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .withAttestationSupport() + .build(); +``` + +Credential chains can probe the host before acquiring a token: + +```java +ManagedIdentityCapabilities capabilities = application + .getManagedIdentityCapabilities() + .get(); + +if (capabilities.maxSupportedBindingStrength() + == MtlsBindingStrength.KEY_GUARD) { + // The host and optional extension can produce a KeyGuard binding. +} +``` + +Successful capability discovery is cached per application instance and checks the selected managed identity source, optional +provider availability, IMDS v2 platform metadata, and local KeyGuard +availability. It may create or reopen a persisted KeyGuard probe key, but it does +not issue a credential or acquire an access token. +Failed IMDS/KeyGuard probes are not cached permanently and can be retried. + +Callers can also enforce a fail-closed minimum: + +```java +MtlsPopOptions options = MtlsPopOptions.builder() + .minimumBindingStrength(MtlsBindingStrength.KEY_GUARD) + .build(); + +ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession(options) + .withAttestationSupport() + .build(); +``` + +The tiers are `NONE`, `SOFTWARE`, and `KEY_GUARD`. The current Java extension +produces only `KEY_GUARD`; `SOFTWARE` is reserved for compatible future +providers. A successful request with a minimum strength guarantees that the +returned binding met that floor. + +`withMtlsProofOfPossession()` can be used without attestation. +`withAttestationSupport()` requires mTLS PoP and makes any attestation failure +fail closed. + +The returned `IMtlsBindingContext` contains an `SSLContext`, +`X509ExtendedKeyManager`, leaf certificate, and complete-certificate key ID. +It also reports the actual `bindingStrength()`; +`IAuthenticationResult.mtlsBindingStrength()` exposes the same value. +JSSE clients can consume the ready-to-use `SSLContext`. Async or engine-based +transports can use the context directly, while applications that require custom +trust anchors can combine the exposed key manager with their own trust +configuration. It contains no token-acquisition helper and no native HTTP +surface. + +Production source remains Java 8 compatible. Use `HttpsURLConnection` for the +primary compatibility proof. Java 11 `java.net.http.HttpClient` may use the same +`SSLContext` in an application compiled separately for Java 11 or later. + +## Cache safety + +MSAL resolves the current binding generation before cache lookup. The extended +access-token cache key includes: + +```text +token_type = mtls_pop +key_id = Base64UrlNoPadding(SHA256(leafCertificate.getEncoded())) +attestation = att0 | att1 +``` + +This prevents: + +- Bearer and mTLS PoP token collisions. +- Token reuse across certificate renewal. +- Returning an mTLS token without a matching live binding context. + +Native key state and `SSLContext` are process-local and excluded from serialized +cache data. + +## Failure behavior + +The flow fails closed when: + +- the optional extension is absent or ambiguous; +- the source is not IMDS VM/VMSS; +- platform metadata or credential responses are incomplete; +- identity selection does not match IMDS metadata; +- KeyGuard or CNG operations fail; +- the bundled `Microsoft.Azure.Security.KeyGuardAttestation` 1.1.5 native + library is missing, corrupt, lacks a valid Windows Authenticode signature, + or cannot be loaded; +- attestation is empty, malformed, expired, or insufficiently fresh; +- the issued certificate does not match the KeyGuard public key; +- the token endpoint does not explicitly return `token_type=mtls_pop`; +- a configured custom HTTP client does not implement + `IMtlsCapableHttpClient`. + +Credential-bound token requests do not follow redirects. A custom HTTP client +must honor `HttpRequest.sslContext()` or `HttpRequest.sslSocketFactory()` and +preserve that no-redirect behavior. + +The initial native package supports Windows x64. Windows ARM64 is not supported +by this release and fails before native loading with an architecture-specific +error. + +Errors and logs must not contain access tokens, attestation JWTs, private-key +material, or native handles. + +## Manual Key Vault validation + +Set: + +```powershell +$env:MSAL_JAVA_MTLS_AKV_URL = "https://.vault.azure.net" +$env:MSAL_JAVA_MTLS_AKV_SECRET_NAME = "" +$env:MSAL_JAVA_MTLS_IDENTITY_CLIENT_ID = "" +$env:MSAL_JAVA_MTLS_MISMATCH_IDENTITY_CLIENT_ID = "" +$env:MSAL_JAVA_MTLS_EXPECTED_SECRET_VALUE = "" +$env:MSAL_JAVA_MTLS_FORCE_REFRESH = "true" # optional +$env:MSAL_JAVA_MTLS_TOKEN_ONLY = "true" # optional: skip Key Vault call +``` + +Run: + +```powershell +.\run-java-msi-v2-mtls-devapp.ps1 +``` + +The app verifies: + +- explicit `mtls_pop`; +- returned certificate and binding context; +- JWT `cnf.x5t#S256` equals the complete-certificate key ID; +- independent Java 8 `HttpsURLConnection` receives Key Vault HTTP 200; +- the same valid `mtls_pop` token without its binding certificate receives + HTTP 401 `Unauthorized`; +- token A with binding B is rejected when a distinct second managed identity + client ID is supplied; +- second acquisition is `TokenSource.CACHE` with the same binding generation; +- optional force refresh is `TokenSource.IDENTITY_PROVIDER` and still receives + HTTP 200. diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplier.java index c6545cf7..d3191743 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplier.java @@ -6,8 +6,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.MalformedURLException; +import java.net.URL; import java.time.Instant; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; class AcquireTokenByManagedIdentitySupplier extends AuthenticationResultSupplier { @@ -39,10 +43,27 @@ AuthenticationResult execute() throws Exception { ); CacheRefreshReason cacheRefreshReason = CacheRefreshReason.NOT_APPLICABLE; + ManagedIdentityMtlsBinding mtlsBinding = null; + + if (managedIdentityParameters.mtlsProofOfPossession()) { + mtlsBinding = resolveMtlsBinding(); + MtlsBindingStrength requiredStrength = + managedIdentityParameters.attestationSupport() + ? MtlsBindingStrength.KEY_GUARD + : managedIdentityParameters.minimumBindingStrength(); + validateMinimumBindingStrength(mtlsBinding, requiredStrength); + String extCacheKeyHash = managedIdentityParameters + .computeMtlsExtCacheKeyHash( + mtlsBinding.bindingContext().keyId()); + msalRequest.extCacheKeyHash(extCacheKeyHash); + } if (managedIdentityParameters.forceRefresh) { LOG.debug("ForceRefresh set to true. Skipping cache lookup and attempting to acquire new token"); - return fetchNewAccessTokenAndSaveToCache(tokenRequestExecutor, CacheRefreshReason.FORCE_REFRESH); + return fetchNewAccessTokenAndSaveToCache( + tokenRequestExecutor, + CacheRefreshReason.FORCE_REFRESH, + mtlsBinding); } @@ -66,6 +87,7 @@ AuthenticationResult execute() throws Exception { this.clientApplication, context, null); + silentRequest.extCacheKeyHash(msalRequest.extCacheKeyHash()); AcquireTokenSilentSupplier supplier = new AcquireTokenSilentSupplier( this.clientApplication, @@ -82,21 +104,32 @@ AuthenticationResult execute() throws Exception { if (cacheRefreshReason == CacheRefreshReason.NOT_APPLICABLE) { LOG.debug("Returning token from cache"); result.metadata().tokenSource(TokenSource.CACHE); - return result; + return mtlsBinding == null + ? result + : result.withMtlsBindingContext(mtlsBinding.bindingContext()); } else { if (cacheRefreshReason == CacheRefreshReason.CLAIMS) { LOG.debug("Claims are passed, creating token hash and refreshing the token"); managedIdentityParameters.revokedTokenHash = StringHelper.createSha256HashHexString(result.accessToken()); - return fetchNewAccessTokenAndSaveToCache(tokenRequestExecutor, CacheRefreshReason.CLAIMS); + return fetchNewAccessTokenAndSaveToCache( + tokenRequestExecutor, + CacheRefreshReason.CLAIMS, + mtlsBinding); } LOG.debug("Refreshing access token. Cache refresh reason: {}", cacheRefreshReason); - return fetchNewAccessTokenAndSaveToCache(tokenRequestExecutor, cacheRefreshReason); + return fetchNewAccessTokenAndSaveToCache( + tokenRequestExecutor, + cacheRefreshReason, + mtlsBinding); } } catch (MsalClientException ex) { if (ex.errorCode().equals(AuthenticationErrorCode.CACHE_MISS)) { LOG.debug("Cache lookup failed: {}", ex.getMessage()); - return fetchNewAccessTokenAndSaveToCache(tokenRequestExecutor, cacheRefreshReason); + return fetchNewAccessTokenAndSaveToCache( + tokenRequestExecutor, + cacheRefreshReason, + mtlsBinding); } else { LOG.error("Error occurred while cache lookup: {}", ex.getMessage()); throw ex; @@ -104,24 +137,40 @@ AuthenticationResult execute() throws Exception { } } - private AuthenticationResult fetchNewAccessTokenAndSaveToCache(TokenRequestExecutor tokenRequestExecutor, CacheRefreshReason cacheRefreshReason) { - - ManagedIdentityClient managedIdentityClient = new ManagedIdentityClient(msalRequest, tokenRequestExecutor.getServiceBundle()); - - LOG.debug("[Managed Identity] Managed Identity source and ID type identified and set successfully, request will use Managed Identity for {}", - managedIdentityClient.managedIdentitySource.managedIdentitySourceType.name()); - - ManagedIdentityResponse managedIdentityResponse = managedIdentityClient - .getManagedIdentityResponse(managedIdentityParameters); + private AuthenticationResult fetchNewAccessTokenAndSaveToCache( + TokenRequestExecutor tokenRequestExecutor, + CacheRefreshReason cacheRefreshReason, + ManagedIdentityMtlsBinding mtlsBinding) { + + AuthenticationResult authenticationResult; + if (mtlsBinding != null) { + authenticationResult = acquireMtlsPopToken( + mtlsBinding, + tokenRequestExecutor); + } else { + ManagedIdentityClient managedIdentityClient = + new ManagedIdentityClient(msalRequest, tokenRequestExecutor.getServiceBundle()); + + LOG.debug("[Managed Identity] Managed Identity source and ID type identified and set successfully, request will use Managed Identity for {}", + managedIdentityClient.managedIdentitySource.managedIdentitySourceType.name()); + + ManagedIdentityResponse managedIdentityResponse = managedIdentityClient + .getManagedIdentityResponse(managedIdentityParameters); + authenticationResult = + createFromManagedIdentityResponse( + managedIdentityResponse, + null); + } - AuthenticationResult authenticationResult = createFromManagedIdentityResponse(managedIdentityResponse); clientApplication.tokenCache.saveTokens(tokenRequestExecutor, authenticationResult, clientApplication.authenticationAuthority.host); authenticationResult.metadata().tokenSource(TokenSource.IDENTITY_PROVIDER); authenticationResult.metadata().cacheRefreshReason(cacheRefreshReason); return authenticationResult; } - private AuthenticationResult createFromManagedIdentityResponse(ManagedIdentityResponse managedIdentityResponse) { + private AuthenticationResult createFromManagedIdentityResponse( + ManagedIdentityResponse managedIdentityResponse, + ManagedIdentityMtlsBinding mtlsBinding) { long expiresOn = getExpiresOnFromManagedIdentityTimestamp(managedIdentityResponse.expiresOn); long refreshOn = calculateRefreshOn(expiresOn); AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder() @@ -136,9 +185,192 @@ private AuthenticationResult createFromManagedIdentityResponse(ManagedIdentityRe .extExpiresOn(0) .refreshOn(refreshOn) .metadata(metadata) + .tokenType(managedIdentityResponse.getTokenType()) + .isPopAuthorization(mtlsBinding == null ? null : Boolean.TRUE) + .mtlsBindingContext(mtlsBinding == null ? null : mtlsBinding.bindingContext()) .build(); } + private ManagedIdentityMtlsBinding resolveMtlsBinding() { + ManagedIdentityApplication application = + (ManagedIdentityApplication) msalRequest.application(); + ManagedIdentityMtlsRequest request = createMtlsProviderRequest( + application, + msalRequest.requestContext(), + managedIdentityParameters.attestationSupport()); + return getMtlsProviderBinding( + ManagedIdentityMtlsProviderLoader.load(), + request); + } + + static ManagedIdentityMtlsRequest createMtlsProviderRequest( + ManagedIdentityApplication application, + RequestContext requestContext, + boolean attestationEnabled) { + ManagedIdentitySourceType source = + ManagedIdentityClient.getManagedIdentitySource(); + if (source != ManagedIdentitySourceType.DEFAULT_TO_IMDS + && source != ManagedIdentitySourceType.IMDS) { + throw new MsalClientException( + "Managed identity mTLS PoP is supported only on the IMDS v2 VM/VMSS source.", + MsalError.MANAGED_IDENTITY_MTLS_UNSUPPORTED); + } + + ManagedIdentityId identity = application.getManagedIdentityId(); + String queryName = null; + String queryValue = identity.getUserAssignedId(); + switch (identity.getIdType()) { + case CLIENT_ID: + queryName = Constants.MANAGED_IDENTITY_CLIENT_ID; + break; + case RESOURCE_ID: + queryName = Constants.MANAGED_IDENTITY_RESOURCE_ID_IMDS; + break; + case OBJECT_ID: + queryName = Constants.MANAGED_IDENTITY_OBJECT_ID; + break; + case SYSTEM_ASSIGNED: + queryValue = null; + break; + default: + throw new MsalClientException( + "Unsupported managed identity selector for mTLS PoP.", + MsalError.MANAGED_IDENTITY_MTLS_UNSUPPORTED); + } + + final ServiceBundle serviceBundle = application.serviceBundle(); + final HttpHelper imdsHttpHelper = new HttpHelper( + application, + new IMDSRetryPolicy()); + IManagedIdentityMtlsHttpClient httpClient = createMtlsProviderHttpClient( + imdsHttpHelper, + serviceBundle, + requestContext); + + String bindingCacheKey = identity.getIdType().name() + ":" + + (queryValue == null ? "" : queryValue) + + (attestationEnabled + ? ":att1" : ":att0"); + return new ManagedIdentityMtlsRequest( + queryName, + queryValue, + bindingCacheKey, + requestContext.correlationId(), + httpClient, + attestationEnabled); + } + + static ManagedIdentityMtlsBinding getMtlsProviderBinding( + IManagedIdentityMtlsProvider provider, + ManagedIdentityMtlsRequest request) { + try { + return provider.getOrCreateBinding(request); + } catch (MsalException e) { + throw e; + } catch (RuntimeException e) { + MsalClientException wrapped = new MsalClientException( + "The managed identity mTLS provider failed.", + MsalError.MANAGED_IDENTITY_MTLS_REQUEST_FAILED); + wrapped.initCause(e); + throw wrapped; + } + } + + static void validateMinimumBindingStrength( + ManagedIdentityMtlsBinding binding, + MtlsBindingStrength requiredStrength) { + MtlsBindingStrength actualStrength = + binding.bindingContext().bindingStrength(); + if (!actualStrength.meets(requiredStrength)) { + throw new MsalClientException( + "The managed identity host produced mTLS binding strength " + + actualStrength + ", which does not meet the required " + + requiredStrength + " minimum.", + MsalError.MANAGED_IDENTITY_MTLS_MINIMUM_STRENGTH_NOT_MET); + } + } + + static IManagedIdentityMtlsHttpClient createMtlsProviderHttpClient( + HttpHelper imdsHttpHelper, + ServiceBundle serviceBundle, + RequestContext requestContext) { + return request -> { + HttpMethod method; + if ("GET".equalsIgnoreCase(request.method())) { + method = HttpMethod.GET; + } else if ("POST".equalsIgnoreCase(request.method())) { + method = HttpMethod.POST; + } else { + throw new MsalClientException( + "Unsupported IMDS mTLS provider HTTP method: " + request.method(), + MsalError.MANAGED_IDENTITY_MTLS_REQUEST_FAILED); + } + + HttpRequest httpRequest = new HttpRequest( + method, + request.url(), + request.headers(), + request.body()); + IHttpResponse response = imdsHttpHelper + .executeHttpRequest(httpRequest, requestContext, serviceBundle); + return new ManagedIdentityMtlsHttpResponse( + response.statusCode(), + response.body(), + response.headers()); + }; + } + + private AuthenticationResult acquireMtlsPopToken( + ManagedIdentityMtlsBinding binding, + TokenRequestExecutor tokenRequestExecutor) { + if (!(clientApplication.httpClient() instanceof IMtlsCapableHttpClient)) { + throw new MsalClientException( + "The configured custom HTTP client does not declare support for request-specific mTLS. " + + "Implement IMtlsCapableHttpClient and honor HttpRequest.sslContext() " + + "or HttpRequest.sslSocketFactory().", + MsalError.MANAGED_IDENTITY_MTLS_HTTP_CLIENT_UNSUPPORTED); + } + + String scope = managedIdentityParameters.resource().endsWith("/.default") + ? managedIdentityParameters.resource() + : managedIdentityParameters.resource().replaceAll("/+$", "") + "/.default"; + Map body = new HashMap<>(); + body.put("grant_type", "client_credentials"); + body.put("client_id", binding.clientId()); + body.put("scope", scope); + body.put("token_type", "mtls_pop"); + AuthenticationResult result; + try { + result = tokenRequestExecutor.executeTokenRequest( + new URL(binding.tokenEndpoint()), + binding.bindingContext().sslContext(), + body); + } catch (MalformedURLException e) { + throw new MsalClientException( + "The managed identity mTLS token endpoint is invalid.", + MsalError.MANAGED_IDENTITY_MTLS_REQUEST_FAILED); + } catch (java.io.IOException e) { + throw new MsalClientException(e); + } + + validateMtlsTokenResponse(result); + return result.withMtlsBindingContext( + binding.bindingContext(), + managedIdentityParameters.resource()); + } + + static void validateMtlsTokenResponse( + IAuthenticationResult tokenResponse) { + if (tokenResponse == null + || StringHelper.isBlank(tokenResponse.accessToken()) + || !"mtls_pop".equals(tokenResponse.tokenType())) { + throw new MsalServiceException( + "The managed identity mTLS endpoint did not explicitly return token_type=mtls_pop.", + MsalError.MANAGED_IDENTITY_MTLS_TOKEN_TYPE_INVALID, + ManagedIdentitySourceType.IMDS); + } + } + static long getExpiresOnFromManagedIdentityTimestamp(String dateTimeStamp) { if (dateTimeStamp == null || dateTimeStamp.isEmpty()) { return 0; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationResult.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationResult.java index d87dfc4b..3205e752 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationResult.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationResult.java @@ -5,6 +5,9 @@ import java.util.Date; import java.util.Objects; +import java.io.InvalidObjectException; +import java.io.ObjectStreamException; +import java.security.cert.X509Certificate; final class AuthenticationResult implements IAuthenticationResult { private static final long serialVersionUID = 1L; @@ -25,8 +28,19 @@ final class AuthenticationResult implements IAuthenticationResult { private final String scopes; private final AuthenticationResultMetadata metadata; private final Boolean isPopAuthorization; + private final String tokenType; + private final transient IMtlsBindingContext mtlsBindingContext; + + private Object readResolve() throws ObjectStreamException { + if ("mtls_pop".equals(tokenType) && mtlsBindingContext == null) { + throw new InvalidObjectException( + "An mTLS PoP authentication result cannot be restored without " + + "its process-local binding context."); + } + return this; + } - AuthenticationResult(String accessToken, long expiresOn, long extExpiresOn, String refreshToken, Long refreshOn, String familyId, String idToken, AccountCacheEntity accountCacheEntity, String environment, String scopes, AuthenticationResultMetadata metadata, Boolean isPopAuthorization) { + AuthenticationResult(String accessToken, long expiresOn, long extExpiresOn, String refreshToken, Long refreshOn, String familyId, String idToken, AccountCacheEntity accountCacheEntity, String environment, String scopes, AuthenticationResultMetadata metadata, Boolean isPopAuthorization, String tokenType, IMtlsBindingContext mtlsBindingContext) { this.accessToken = accessToken; this.expiresOn = expiresOn; this.extExpiresOn = extExpiresOn; @@ -39,6 +53,8 @@ final class AuthenticationResult implements IAuthenticationResult { this.scopes = scopes; this.metadata = metadata == null ? AuthenticationResultMetadata.builder().build() : metadata; this.isPopAuthorization = isPopAuthorization; + this.tokenType = StringHelper.isBlank(tokenType) ? "Bearer" : tokenType; + this.mtlsBindingContext = mtlsBindingContext; this.expiresOnDate = new Date(expiresOn * 1000); } @@ -129,6 +145,59 @@ Boolean isPopAuthorization() { return this.isPopAuthorization; } + AuthenticationResult withMtlsBindingContext(IMtlsBindingContext bindingContext) { + return withMtlsBindingContext( + bindingContext, + null, + "mtls_pop"); + } + + AuthenticationResult withMtlsBindingContext( + IMtlsBindingContext bindingContext, + String defaultScopes) { + return withMtlsBindingContext( + bindingContext, + defaultScopes, + tokenType); + } + + private AuthenticationResult withMtlsBindingContext( + IMtlsBindingContext bindingContext, + String defaultScopes, + String resultTokenType) { + return AuthenticationResult.builder() + .accessToken(accessToken) + .expiresOn(expiresOn) + .extExpiresOn(extExpiresOn) + .refreshToken(refreshToken) + .refreshOn(refreshOn) + .familyId(familyId) + .idToken(idToken) + .accountCacheEntity(accountCacheEntity) + .environment(environment) + .scopes(StringHelper.isBlank(scopes) ? defaultScopes : scopes) + .metadata(metadata) + .isPopAuthorization(Boolean.TRUE) + .tokenType(resultTokenType) + .mtlsBindingContext(bindingContext) + .build(); + } + + @Override + public String tokenType() { + return StringHelper.isBlank(tokenType) ? "Bearer" : tokenType; + } + + @Override + public X509Certificate bindingCertificate() { + return mtlsBindingContext == null ? null : mtlsBindingContext.bindingCertificate(); + } + + @Override + public IMtlsBindingContext mtlsBindingContext() { + return mtlsBindingContext; + } + static AuthenticationResultBuilder builder() { return new AuthenticationResultBuilder(); } @@ -146,6 +215,8 @@ static class AuthenticationResultBuilder { private String scopes; private AuthenticationResultMetadata metadata; private Boolean isPopAuthorization; + private String tokenType; + private IMtlsBindingContext mtlsBindingContext; AuthenticationResultBuilder() { } @@ -210,12 +281,28 @@ public AuthenticationResultBuilder isPopAuthorization(Boolean isPopAuthorization return this; } + public AuthenticationResultBuilder tokenType(String tokenType) { + this.tokenType = tokenType; + return this; + } + + public AuthenticationResultBuilder mtlsBindingContext(IMtlsBindingContext mtlsBindingContext) { + this.mtlsBindingContext = mtlsBindingContext; + return this; + } + public AuthenticationResult build() { - return new AuthenticationResult(this.accessToken, this.expiresOn, this.extExpiresOn, this.refreshToken, this.refreshOn, this.familyId, this.idToken, this.accountCacheEntity, this.environment, this.scopes, this.metadata, this.isPopAuthorization); + return new AuthenticationResult(this.accessToken, this.expiresOn, this.extExpiresOn, this.refreshToken, this.refreshOn, this.familyId, this.idToken, this.accountCacheEntity, this.environment, this.scopes, this.metadata, this.isPopAuthorization, this.tokenType, this.mtlsBindingContext); } public String toString() { - return "AuthenticationResult.AuthenticationResultBuilder(accessToken=" + this.accessToken + ", expiresOn=" + this.expiresOn + ", extExpiresOn=" + this.extExpiresOn + ", refreshToken=" + this.refreshToken + ", refreshOn=" + this.refreshOn + ", familyId=" + this.familyId + ", idToken=" + this.idToken + ", accountCacheEntity=" + this.accountCacheEntity + ", environment=" + this.environment + ", scopes=" + this.scopes + ", metadata=" + this.metadata + ", isPopAuthorization=" + this.isPopAuthorization + ")"; + return "AuthenticationResult.AuthenticationResultBuilder(expiresOn=" + this.expiresOn + + ", extExpiresOn=" + this.extExpiresOn + + ", environment=" + this.environment + + ", scopes=" + this.scopes + + ", metadata=" + this.metadata + + ", isPopAuthorization=" + this.isPopAuthorization + + ", tokenType=" + this.tokenType + ")"; } } @@ -243,6 +330,7 @@ public boolean equals(Object o) { if (!Objects.equals(environment, other.environment)) return false; if (!Objects.equals(expiresOnDate, other.expiresOnDate)) return false; if (!Objects.equals(scopes, other.scopes)) return false; + if (!Objects.equals(tokenType, other.tokenType)) return false; return Objects.equals(metadata, other.metadata); } @@ -264,6 +352,7 @@ public int hashCode() { result = result * 59 + (this.environment == null ? 43 : this.environment.hashCode()); result = result * 59 + (this.expiresOnDate == null ? 43 : this.expiresOnDate.hashCode()); result = result * 59 + (this.scopes == null ? 43 : this.scopes.hashCode()); + result = result * 59 + (this.tokenType == null ? 43 : this.tokenType.hashCode()); result = result * 59 + (this.metadata == null ? 43 : this.metadata.hashCode()); return result; } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/DefaultHttpClient.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/DefaultHttpClient.java index 7a88a2a4..6f0fbe8b 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/DefaultHttpClient.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/DefaultHttpClient.java @@ -20,7 +20,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; -class DefaultHttpClient implements IHttpClient { +class DefaultHttpClient implements IMtlsCapableHttpClient { private static final Logger LOG = LoggerFactory.getLogger(DefaultHttpClient.class); final Proxy proxy; @@ -50,7 +50,7 @@ public IHttpResponse send(HttpRequest httpRequest) throws Exception { private HttpResponse executeHttpGet(HttpRequest httpRequest) throws Exception { - final HttpURLConnection conn = openConnection(httpRequest.url()); + final HttpURLConnection conn = openConnection(httpRequest.url(), httpRequest.sslSocketFactory()); configureAdditionalHeaders(conn, httpRequest); return readResponseFromConnection(conn); @@ -58,7 +58,7 @@ private HttpResponse executeHttpGet(HttpRequest httpRequest) throws Exception { private HttpResponse executeHttpPost(HttpRequest httpRequest) throws Exception { - final HttpURLConnection conn = openConnection(httpRequest.url()); + final HttpURLConnection conn = openConnection(httpRequest.url(), httpRequest.sslSocketFactory()); configureAdditionalHeaders(conn, httpRequest); conn.setRequestMethod("POST"); conn.setDoOutput(true); @@ -79,6 +79,11 @@ private HttpResponse executeHttpPost(HttpRequest httpRequest) throws Exception { HttpURLConnection openConnection(final URL finalURL) throws IOException { + return openConnection(finalURL, null); + } + + HttpURLConnection openConnection(final URL finalURL, SSLSocketFactory requestSslSocketFactory) + throws IOException { URLConnection connection; if (proxy != null) { @@ -93,8 +98,13 @@ HttpURLConnection openConnection(final URL finalURL) if (connection instanceof HttpsURLConnection) { HttpsURLConnection httpsConnection = (HttpsURLConnection) connection; - if (sslSocketFactory != null) { - httpsConnection.setSSLSocketFactory(sslSocketFactory); + SSLSocketFactory effectiveSslSocketFactory = + requestSslSocketFactory != null ? requestSslSocketFactory : sslSocketFactory; + if (effectiveSslSocketFactory != null) { + httpsConnection.setSSLSocketFactory(effectiveSslSocketFactory); + } + if (requestSslSocketFactory != null) { + httpsConnection.setInstanceFollowRedirects(false); } return httpsConnection; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpRequest.java index 2923f799..2c852a5d 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpRequest.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpRequest.java @@ -7,6 +7,8 @@ import java.net.URL; import java.util.Map; import java.util.Objects; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; /** * Contains information about outgoing HTTP request. Should be adapted to HTTP request for HTTP @@ -34,6 +36,9 @@ public class HttpRequest { */ private String body; + private transient SSLSocketFactory sslSocketFactory; + private transient SSLContext sslContext; + HttpRequest(HttpMethod httpMethod, String url) { this.httpMethod = httpMethod; this.url = createUrlFromString(url); @@ -100,6 +105,35 @@ public String body() { return this.body; } + /** + * Returns a request-specific JSSE socket factory, when the request requires mTLS. + */ + public SSLSocketFactory sslSocketFactory() { + return sslSocketFactory; + } + + /** + * Returns the request-specific JSSE context, when the request requires mTLS. + * + *

Async or engine-based custom HTTP clients can consume this context directly + * instead of adapting the socket factory.

+ */ + public SSLContext sslContext() { + return sslContext; + } + + HttpRequest sslSocketFactory(SSLSocketFactory sslSocketFactory) { + this.sslSocketFactory = sslSocketFactory; + return this; + } + + HttpRequest sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + this.sslSocketFactory = + sslContext == null ? null : sslContext.getSocketFactory(); + return this; + } + //These methods are based on those generated by Lombok's @EqualsAndHashCode annotation. //They have the same functionality as the generated methods, but were refactored for readability. @Override diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAuthenticationResult.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAuthenticationResult.java index 934a2d2c..e9f6d4f0 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAuthenticationResult.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAuthenticationResult.java @@ -4,6 +4,7 @@ package com.microsoft.aad.msal4j; import java.io.Serializable; +import java.security.cert.X509Certificate; /** * Interface representing the results of token acquisition operation. @@ -51,4 +52,35 @@ public interface IAuthenticationResult extends Serializable { default AuthenticationResultMetadata metadata() { return AuthenticationResultMetadata.builder().build(); } + + /** + * @return token type returned by the identity provider + */ + default String tokenType() { + return "Bearer"; + } + + /** + * @return binding certificate for an mTLS PoP result, otherwise null + */ + default X509Certificate bindingCertificate() { + return null; + } + + /** + * @return live process-local binding context for an mTLS PoP result, otherwise null + */ + default IMtlsBindingContext mtlsBindingContext() { + return null; + } + + /** + * @return strength of the live mTLS binding, or {@link MtlsBindingStrength#NONE} + */ + default MtlsBindingStrength mtlsBindingStrength() { + IMtlsBindingContext context = mtlsBindingContext(); + return context == null + ? MtlsBindingStrength.NONE + : context.bindingStrength(); + } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityApplication.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityApplication.java index 9aa56551..e1e0e8d4 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityApplication.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityApplication.java @@ -14,6 +14,19 @@ */ public interface IManagedIdentityApplication extends IApplicationBase { + /** + * Detects the managed identity source and strongest mTLS binding available + * without acquiring an access token. + */ + default CompletableFuture + getManagedIdentityCapabilities() { + return CompletableFuture.completedFuture( + new ManagedIdentityCapabilities( + ManagedIdentitySourceType.NONE, + MtlsBindingStrength.NONE, + "Capability discovery is not implemented by this application.")); + } + /** * Acquires tokens from the configured managed identity on an azure resource. * diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityMtlsHttpClient.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityMtlsHttpClient.java new file mode 100644 index 00000000..bfc88ea5 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityMtlsHttpClient.java @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * MSAL-owned HTTP callback used by the optional mTLS provider for IMDS v2 requests. + */ +public interface IManagedIdentityMtlsHttpClient { + + ManagedIdentityMtlsHttpResponse execute(ManagedIdentityMtlsHttpRequest request); +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityMtlsProvider.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityMtlsProvider.java new file mode 100644 index 00000000..6c51704f --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IManagedIdentityMtlsProvider.java @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Optional platform extension for attested KeyGuard managed identity mTLS PoP bindings. + * + *

Implementations are discovered with {@link java.util.ServiceLoader}. MSAL core remains + * loadable when the optional Windows extension is absent.

+ */ +public interface IManagedIdentityMtlsProvider { + + ManagedIdentityMtlsBinding getOrCreateBinding(ManagedIdentityMtlsRequest request); + + /** + * Probes the strongest binding this provider can produce without acquiring a token. + */ + default MtlsBindingStrength getMaxSupportedBindingStrength( + ManagedIdentityMtlsRequest request) { + return MtlsBindingStrength.NONE; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IMtlsBindingContext.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IMtlsBindingContext.java new file mode 100644 index 00000000..0b686b85 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IMtlsBindingContext.java @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509ExtendedKeyManager; +import java.security.cert.X509Certificate; + +/** + * Process-local mTLS binding capability associated with an mTLS PoP access token. + * + *

The private key is not exportable. Applications can use the returned + * {@link SSLContext} with a Java JSSE HTTP stack, or use the returned + * {@link X509ExtendedKeyManager} to build a transport-specific TLS context.

+ */ +public interface IMtlsBindingContext { + + /** + * Returns the strength actually used by this binding context. + */ + default MtlsBindingStrength bindingStrength() { + return MtlsBindingStrength.SOFTWARE; + } + + /** + * Returns a ready-to-use JSSE context. The context uses the JVM's default trust + * managers. + */ + SSLContext sslContext(); + + /** + * Returns the key manager backed by the non-exportable binding key. + * + *

Applications that require custom trust anchors or a transport-specific TLS + * context can combine this key manager with their own trust configuration.

+ */ + X509ExtendedKeyManager keyManager(); + + X509Certificate bindingCertificate(); + + /** + * Base64URL-without-padding SHA-256 digest of the complete leaf certificate DER. + */ + String keyId(); +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IMtlsCapableHttpClient.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IMtlsCapableHttpClient.java new file mode 100644 index 00000000..07972230 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IMtlsCapableHttpClient.java @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Marker contract for custom HTTP clients that honor request-specific mTLS settings. + * + *

Implementations must use {@link HttpRequest#sslContext()} or + * {@link HttpRequest#sslSocketFactory()} when present and must not automatically + * follow redirects for that credential-bound request.

+ * + *

Engine-based and asynchronous transports should prefer the + * {@link javax.net.ssl.SSLContext}. Socket-based JSSE transports can use the + * {@link javax.net.ssl.SSLSocketFactory}.

+ */ +public interface IMtlsCapableHttpClient extends IHttpClient { +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityApplication.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityApplication.java index 5385e9e0..54520819 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityApplication.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityApplication.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; /** * Class to be used to acquire tokens for managed identity. @@ -18,6 +20,8 @@ public class ManagedIdentityApplication extends AbstractApplicationBase implemen private final ManagedIdentityId managedIdentityId; private List clientCapabilities; + private volatile CompletableFuture + managedIdentityCapabilities; static TokenCache sharedTokenCache = new TokenCache(); //Deprecated the field in favor of the static getManagedIdentitySource method @@ -59,6 +63,94 @@ public ManagedIdentityId getManagedIdentityId() { } public List getClientCapabilities() { return this.clientCapabilities; } + + @Override + public synchronized CompletableFuture + getManagedIdentityCapabilities() { + if (managedIdentityCapabilities != null) { + return managedIdentityCapabilities; + } + Supplier supplier = + this::detectManagedIdentityCapabilities; + ExecutorService executorService = + serviceBundle().getExecutorService(); + CompletableFuture discovery = + executorService == null + ? CompletableFuture.supplyAsync(supplier) + : CompletableFuture.supplyAsync(supplier, executorService); + managedIdentityCapabilities = discovery; + discovery.whenComplete((capabilities, error) -> { + if (error != null + || shouldRetryCapabilityDiscovery(capabilities)) { + synchronized (ManagedIdentityApplication.this) { + if (managedIdentityCapabilities == discovery) { + managedIdentityCapabilities = null; + } + } + } + }); + return discovery; + } + + private static boolean shouldRetryCapabilityDiscovery( + ManagedIdentityCapabilities capabilities) { + if (capabilities == null + || capabilities.isMtlsPopSupportedByHost()) { + return false; + } + return capabilities.source() == ManagedIdentitySourceType.IMDS + || capabilities.source() + == ManagedIdentitySourceType.DEFAULT_TO_IMDS; + } + + private ManagedIdentityCapabilities detectManagedIdentityCapabilities() { + ManagedIdentitySourceType source = + ManagedIdentityClient.getManagedIdentitySource(); + if (source != ManagedIdentitySourceType.DEFAULT_TO_IMDS + && source != ManagedIdentitySourceType.IMDS) { + return new ManagedIdentityCapabilities( + source, + MtlsBindingStrength.NONE, + "Managed identity mTLS PoP is supported only on the IMDS v2 VM/VMSS source."); + } + + ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder("https://management.azure.com") + .build(); + RequestContext requestContext = new RequestContext( + this, + managedIdentityId.getIdType() + == ManagedIdentityIdType.SYSTEM_ASSIGNED + ? PublicApi.ACQUIRE_TOKEN_BY_SYSTEM_ASSIGNED_MANAGED_IDENTITY + : PublicApi.ACQUIRE_TOKEN_BY_USER_ASSIGNED_MANAGED_IDENTITY, + parameters); + + try { + IManagedIdentityMtlsProvider provider = + ManagedIdentityMtlsProviderLoader.load(); + ManagedIdentityMtlsRequest request = + AcquireTokenByManagedIdentitySupplier + .createMtlsProviderRequest( + this, + requestContext, + false); + MtlsBindingStrength strength = + provider.getMaxSupportedBindingStrength(request); + return new ManagedIdentityCapabilities( + strength == MtlsBindingStrength.NONE + ? source + : ManagedIdentitySourceType.IMDS, + strength, + strength == MtlsBindingStrength.NONE + ? "The configured mTLS provider did not report a supported binding." + : null); + } catch (RuntimeException e) { + return new ManagedIdentityCapabilities( + source, + MtlsBindingStrength.NONE, + e.getMessage()); + } + } @Override public CompletableFuture acquireTokenForManagedIdentity(ManagedIdentityParameters managedIdentityParameters) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityCapabilities.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityCapabilities.java new file mode 100644 index 00000000..502c3fab --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityCapabilities.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Managed identity and mTLS PoP capabilities detected for the current host. + */ +public final class ManagedIdentityCapabilities { + + private final ManagedIdentitySourceType source; + private final MtlsBindingStrength maximumBindingStrength; + private final String errorReason; + + ManagedIdentityCapabilities( + ManagedIdentitySourceType source, + MtlsBindingStrength maximumBindingStrength, + String errorReason) { + this.source = source; + this.maximumBindingStrength = maximumBindingStrength; + this.errorReason = errorReason; + } + + public ManagedIdentitySourceType source() { + return source; + } + + public MtlsBindingStrength maxSupportedBindingStrength() { + return maximumBindingStrength; + } + + public boolean isMtlsPopSupportedByHost() { + return maximumBindingStrength != MtlsBindingStrength.NONE; + } + + public String errorReason() { + return errorReason; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsBinding.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsBinding.java new file mode 100644 index 00000000..92e07cad --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsBinding.java @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Immutable binding generation returned by the optional managed identity mTLS provider. + */ +public final class ManagedIdentityMtlsBinding { + + private final IMtlsBindingContext bindingContext; + private final String clientId; + private final String tokenEndpoint; + + public ManagedIdentityMtlsBinding( + IMtlsBindingContext bindingContext, + String clientId, + String tokenEndpoint) { + if (bindingContext == null) { + throw new NullPointerException("bindingContext"); + } + if (clientId == null || clientId.trim().isEmpty()) { + throw new IllegalArgumentException("clientId must not be blank"); + } + if (tokenEndpoint == null || tokenEndpoint.trim().isEmpty()) { + throw new IllegalArgumentException("tokenEndpoint must not be blank"); + } + try { + URL endpoint = new URL(tokenEndpoint); + if (!"https".equalsIgnoreCase(endpoint.getProtocol())) { + throw new IllegalArgumentException( + "tokenEndpoint must use HTTPS"); + } + } catch (MalformedURLException e) { + throw new IllegalArgumentException( + "tokenEndpoint must be a valid HTTPS URL", e); + } + this.bindingContext = bindingContext; + this.clientId = clientId; + this.tokenEndpoint = tokenEndpoint; + } + + public IMtlsBindingContext bindingContext() { + return bindingContext; + } + + public String clientId() { + return clientId; + } + + public String tokenEndpoint() { + return tokenEndpoint; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsHttpRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsHttpRequest.java new file mode 100644 index 00000000..d08347b5 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsHttpRequest.java @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Immutable IMDS request issued through MSAL's HTTP, retry, proxy and telemetry pipeline. + */ +public final class ManagedIdentityMtlsHttpRequest { + + private final String method; + private final String url; + private final Map headers; + private final String body; + + public ManagedIdentityMtlsHttpRequest( + String method, + String url, + Map headers, + String body) { + this.method = method; + this.url = url; + this.headers = headers == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(headers)); + this.body = body; + } + + public String method() { + return method; + } + + public String url() { + return url; + } + + public Map headers() { + return headers; + } + + public String body() { + return body; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsHttpResponse.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsHttpResponse.java new file mode 100644 index 00000000..1b33c9b4 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsHttpResponse.java @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Response returned to the optional mTLS provider by MSAL's HTTP pipeline. + */ +public final class ManagedIdentityMtlsHttpResponse { + + private final int statusCode; + private final String body; + private final Map> headers; + + public ManagedIdentityMtlsHttpResponse( + int statusCode, + String body, + Map> headers) { + this.statusCode = statusCode; + this.body = body; + this.headers = headers == null + ? Collections.>emptyMap() + : Collections.unmodifiableMap(headers); + } + + public int statusCode() { + return statusCode; + } + + public String body() { + return body; + } + + public Map> headers() { + return headers; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsProviderLoader.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsProviderLoader.java new file mode 100644 index 00000000..e0acaf82 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsProviderLoader.java @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.util.Iterator; +import java.util.ServiceLoader; + +final class ManagedIdentityMtlsProviderLoader { + + private ManagedIdentityMtlsProviderLoader() { + } + + static IManagedIdentityMtlsProvider load() { + ServiceLoader loader = + ServiceLoader.load(IManagedIdentityMtlsProvider.class); + Iterator providers = loader.iterator(); + if (!providers.hasNext()) { + throw new MsalClientException( + "Managed identity mTLS PoP requires the optional " + + "com.microsoft.azure:msal4j-mtls-extensions dependency.", + MsalError.MANAGED_IDENTITY_MTLS_PROVIDER_UNAVAILABLE); + } + + IManagedIdentityMtlsProvider provider = providers.next(); + if (providers.hasNext()) { + throw new MsalClientException( + "Multiple managed identity mTLS providers were found. Configure exactly one provider.", + MsalError.MANAGED_IDENTITY_MTLS_PROVIDER_UNAVAILABLE); + } + return provider; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsRequest.java new file mode 100644 index 00000000..da92692a --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsRequest.java @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Request passed from MSAL core to the optional KeyGuard mTLS provider. + */ +public final class ManagedIdentityMtlsRequest { + + private final String identityQueryParameter; + private final String identityQueryValue; + private final String bindingCacheKey; + private final String correlationId; + private final IManagedIdentityMtlsHttpClient httpClient; + private final boolean attestationEnabled; + + public ManagedIdentityMtlsRequest( + String identityQueryParameter, + String identityQueryValue, + String bindingCacheKey, + String correlationId, + IManagedIdentityMtlsHttpClient httpClient) { + this(identityQueryParameter, identityQueryValue, bindingCacheKey, + correlationId, httpClient, true); + } + + public ManagedIdentityMtlsRequest( + String identityQueryParameter, + String identityQueryValue, + String bindingCacheKey, + String correlationId, + IManagedIdentityMtlsHttpClient httpClient, + boolean attestationEnabled) { + this.identityQueryParameter = identityQueryParameter; + this.identityQueryValue = identityQueryValue; + this.bindingCacheKey = bindingCacheKey; + this.correlationId = correlationId; + this.httpClient = httpClient; + this.attestationEnabled = attestationEnabled; + } + + public String identityQueryParameter() { + return identityQueryParameter; + } + + public String identityQueryValue() { + return identityQueryValue; + } + + public String bindingCacheKey() { + return bindingCacheKey; + } + + public String correlationId() { + return correlationId; + } + + public IManagedIdentityMtlsHttpClient httpClient() { + return httpClient; + } + + public boolean attestationEnabled() { + return attestationEnabled; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityParameters.java index 21335802..35978cfe 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityParameters.java @@ -5,6 +5,8 @@ import java.util.Map; import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; /** * Object containing parameters for managed identity flow. Can be used as parameter to @@ -16,11 +18,23 @@ public class ManagedIdentityParameters implements IAcquireTokenParameters { boolean forceRefresh; String claims; String revokedTokenHash; + boolean mtlsProofOfPossession; + boolean attestationSupport; + MtlsBindingStrength minimumBindingStrength; - private ManagedIdentityParameters(String resource, boolean forceRefresh, String claims) { + private ManagedIdentityParameters( + String resource, + boolean forceRefresh, + String claims, + boolean mtlsProofOfPossession, + boolean attestationSupport, + MtlsBindingStrength minimumBindingStrength) { this.resource = resource; this.forceRefresh = forceRefresh; this.claims = claims; + this.mtlsProofOfPossession = mtlsProofOfPossession; + this.attestationSupport = attestationSupport; + this.minimumBindingStrength = minimumBindingStrength; } @Override @@ -83,10 +97,45 @@ public String revokedTokenHash() { return this.revokedTokenHash; } + public boolean mtlsProofOfPossession() { + return mtlsProofOfPossession; + } + + public boolean attestationSupport() { + return attestationSupport; + } + + public MtlsBindingStrength minimumBindingStrength() { + return minimumBindingStrength; + } + + @Override + public String computeExtCacheKeyHash() { + return ""; + } + + String computeMtlsExtCacheKeyHash(String bindingKeyId) { + if (!mtlsProofOfPossession || StringHelper.isBlank(bindingKeyId)) { + return ""; + } + SortedMap components = new TreeMap<>(); + components.put("token_type", "mtls_pop"); + components.put("key_id", bindingKeyId); + components.put("attestation", attestationSupport ? "att1" : "att0"); + if (minimumBindingStrength != MtlsBindingStrength.NONE) { + components.put("min_strength", minimumBindingStrength.name()); + } + return StringHelper.computeExtCacheKeyHash(components); + } + public static class ManagedIdentityParametersBuilder { private String resource; private boolean forceRefresh; private String claims; + private boolean mtlsProofOfPossession; + private boolean attestationSupport; + private MtlsBindingStrength minimumBindingStrength = + MtlsBindingStrength.NONE; ManagedIdentityParametersBuilder() { } @@ -118,12 +167,55 @@ public ManagedIdentityParametersBuilder claims(String claims) { return this; } + /** + * Requests the KeyGuard managed identity v2 mTLS PoP flow. + */ + public ManagedIdentityParametersBuilder withMtlsProofOfPossession() { + return withMtlsProofOfPossession(MtlsPopOptions.builder().build()); + } + + /** + * Requests mTLS PoP and requires the configured minimum binding strength. + */ + public ManagedIdentityParametersBuilder withMtlsProofOfPossession( + MtlsPopOptions options) { + if (options == null) { + throw new NullPointerException("options"); + } + this.mtlsProofOfPossession = true; + this.minimumBindingStrength = options.minimumBindingStrength(); + return this; + } + + /** + * Requires MAA attestation for the KeyGuard binding key. + * This option requires {@link #withMtlsProofOfPossession()}. + */ + public ManagedIdentityParametersBuilder withAttestationSupport() { + this.attestationSupport = true; + return this; + } + public ManagedIdentityParameters build() { - return new ManagedIdentityParameters(this.resource, this.forceRefresh, this.claims); + if (attestationSupport && !mtlsProofOfPossession) { + throw new IllegalArgumentException( + "Attestation support requires managed identity mTLS PoP."); + } + return new ManagedIdentityParameters( + this.resource, + this.forceRefresh, + this.claims, + this.mtlsProofOfPossession, + this.attestationSupport, + this.minimumBindingStrength); } public String toString() { - return "ManagedIdentityParameters.ManagedIdentityParametersBuilder(resource=" + this.resource + ", forceRefresh=" + this.forceRefresh + ")"; + return "ManagedIdentityParameters.ManagedIdentityParametersBuilder(resource=" + this.resource + + ", forceRefresh=" + this.forceRefresh + + ", mtlsProofOfPossession=" + this.mtlsProofOfPossession + + ", attestationSupport=" + this.attestationSupport + + ", minimumBindingStrength=" + this.minimumBindingStrength + ")"; } } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityResponse.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityResponse.java index f6eb4e7e..fa44f57b 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityResponse.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ManagedIdentityResponse.java @@ -37,6 +37,10 @@ public static ManagedIdentityResponse fromJson(JsonReader jsonReader) throws IOE case "expires_on": response.expiresOn = reader.getString(); break; + case "expires_in": + response.expiresOn = String.valueOf( + (System.currentTimeMillis() / 1000) + reader.getLong()); + break; case "resource": response.resource = reader.getString(); break; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalError.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalError.java index 23d4e546..9405e965 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalError.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalError.java @@ -38,4 +38,22 @@ public class MsalError { public static final String MANAGED_IDENTITY_FILE_READ_ERROR = "managed_identity_file_read_error"; public static final String MANAGED_IDENTITY_RESPONSE_PARSE_FAILURE = "managed_identity_response_parse_failure"; + + public static final String MANAGED_IDENTITY_MTLS_PROVIDER_UNAVAILABLE = + "managed_identity_mtls_provider_unavailable"; + + public static final String MANAGED_IDENTITY_MTLS_TOKEN_TYPE_INVALID = + "managed_identity_mtls_token_type_invalid"; + + public static final String MANAGED_IDENTITY_MTLS_REQUEST_FAILED = + "managed_identity_mtls_request_failed"; + + public static final String MANAGED_IDENTITY_MTLS_UNSUPPORTED = + "managed_identity_mtls_unsupported"; + + public static final String MANAGED_IDENTITY_MTLS_HTTP_CLIENT_UNSUPPORTED = + "managed_identity_mtls_http_client_unsupported"; + + public static final String MANAGED_IDENTITY_MTLS_MINIMUM_STRENGTH_NOT_MET = + "managed_identity_mtls_minimum_strength_not_met"; } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalRequest.java index f730f5bd..e5134ac3 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalRequest.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MsalRequest.java @@ -9,6 +9,7 @@ abstract class MsalRequest { private final AbstractApplicationBase application; private final RequestContext requestContext; private final HttpHeaders headers; + private String extCacheKeyHash; MsalRequest(AbstractApplicationBase clientApplicationBase, AbstractMsalAuthorizationGrant abstractMsalAuthorizationGrant, RequestContext requestContext) { this.application = clientApplicationBase; @@ -44,4 +45,12 @@ RequestContext requestContext() { HttpHeaders headers() { return this.headers; } + + String extCacheKeyHash() { + return extCacheKeyHash; + } + + void extCacheKeyHash(String extCacheKeyHash) { + this.extCacheKeyHash = extCacheKeyHash; + } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsBindingStrength.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsBindingStrength.java new file mode 100644 index 00000000..1b89f06b --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsBindingStrength.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Strength with which an access token can be bound to a cryptographic key. + */ +public enum MtlsBindingStrength { + NONE(0), + SOFTWARE(1), + KEY_GUARD(3); + + private final int value; + + MtlsBindingStrength(int value) { + this.value = value; + } + + public int value() { + return value; + } + + boolean meets(MtlsBindingStrength required) { + return value >= required.value; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsPopOptions.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsPopOptions.java new file mode 100644 index 00000000..88d54eb8 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsPopOptions.java @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Options controlling an mTLS Proof-of-Possession token request. + */ +public final class MtlsPopOptions { + + private final MtlsBindingStrength minimumBindingStrength; + + private MtlsPopOptions(Builder builder) { + this.minimumBindingStrength = builder.minimumBindingStrength; + } + + public MtlsBindingStrength minimumBindingStrength() { + return minimumBindingStrength; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private MtlsBindingStrength minimumBindingStrength = + MtlsBindingStrength.NONE; + + private Builder() { + } + + /** + * Sets the minimum binding strength required for acquisition to succeed. + */ + public Builder minimumBindingStrength( + MtlsBindingStrength minimumBindingStrength) { + if (minimumBindingStrength == null) { + throw new NullPointerException("minimumBindingStrength"); + } + this.minimumBindingStrength = minimumBindingStrength; + return this; + } + + public MtlsPopOptions build() { + return new MtlsPopOptions(this); + } + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java index 49ecc2fc..156f8a0d 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java @@ -10,6 +10,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; class OAuthHttpRequest { @@ -19,6 +21,8 @@ class OAuthHttpRequest { private final Map extraHeaderParams; private final ServiceBundle serviceBundle; private final RequestContext requestContext; + private SSLSocketFactory sslSocketFactory; + private SSLContext sslContext; OAuthHttpRequest(final HttpMethod method, final URL url, @@ -40,6 +44,11 @@ public HttpResponse send() throws IOException { this.url.toString(), httpHeaders, this.query); + if (sslContext != null) { + httpRequest.sslContext(sslContext); + } else { + httpRequest.sslSocketFactory(sslSocketFactory); + } IHttpResponse httpResponse = serviceBundle.getHttpHelper().executeHttpRequest( httpRequest, @@ -107,4 +116,24 @@ void setQuery(String query) { Map getExtraHeaderParams() { return this.extraHeaderParams; } + + OAuthHttpRequest sslSocketFactory(SSLSocketFactory sslSocketFactory) { + this.sslSocketFactory = sslSocketFactory; + return this; + } + + SSLSocketFactory sslSocketFactory() { + return sslSocketFactory; + } + + OAuthHttpRequest sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + this.sslSocketFactory = + sslContext == null ? null : sslContext.getSocketFactory(); + return this; + } + + SSLContext sslContext() { + return sslContext; + } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java index 59a7b903..3afd5323 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java @@ -346,6 +346,10 @@ private static AccessTokenCacheEntity createAccessTokenCacheEntity(TokenRequestE * The algorithm uses sorted key-value concatenation → SHA-256 → Base64URL (cross-SDK compatible). */ private static String computeExtCacheKeyHashForRequest(MsalRequest msalRequest) { + if (!StringHelper.isBlank(msalRequest.extCacheKeyHash())) { + return msalRequest.extCacheKeyHash(); + } + // A RefreshTokenRequest inherits the parent silent request's RequestContext, whose // apiParameters (SilentParameters) carries no client-originated claims and would therefore // return an empty hash. Prefer the hash threaded onto the parent silent request so a refreshed diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java index 8c3cc62b..8a5ff528 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java @@ -8,7 +8,10 @@ import java.io.IOException; import java.net.MalformedURLException; +import java.net.URL; import java.util.*; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; class TokenRequestExecutor { private static final Logger LOG = LoggerFactory.getLogger(TokenRequestExecutor.class); @@ -35,6 +38,30 @@ AuthenticationResult executeTokenRequest() throws IOException { return createAuthenticationResultFromOauthHttpResponse(oauthHttpResponse); } + AuthenticationResult executeTokenRequest( + URL tokenEndpoint, + SSLSocketFactory sslSocketFactory, + Map parameters) throws IOException { + LOG.debug("Sending token request to: {}", tokenEndpoint); + OAuthHttpRequest request = createOauthHttpRequest( + tokenEndpoint, + sslSocketFactory, + parameters); + return createAuthenticationResultFromOauthHttpResponse(request.send()); + } + + AuthenticationResult executeTokenRequest( + URL tokenEndpoint, + SSLContext sslContext, + Map parameters) throws IOException { + LOG.debug("Sending token request to: {}", tokenEndpoint); + OAuthHttpRequest request = createOauthHttpRequest( + tokenEndpoint, + sslContext, + parameters); + return createAuthenticationResultFromOauthHttpResponse(request.send()); + } + OAuthHttpRequest createOauthHttpRequest() throws MalformedURLException { if (requestAuthority.tokenEndpointUrl() == null) { @@ -97,6 +124,88 @@ OAuthHttpRequest createOauthHttpRequest() throws MalformedURLException { return oauthHttpRequest; } + OAuthHttpRequest createOauthHttpRequest( + URL tokenEndpoint, + SSLSocketFactory sslSocketFactory, + Map parameters) { + if (tokenEndpoint == null) { + throw new MsalClientException("The endpoint URI is not specified", + AuthenticationErrorCode.INVALID_ENDPOINT_URI); + } + + OAuthHttpRequest request = new OAuthHttpRequest( + HttpMethod.POST, + tokenEndpoint, + msalRequest.headers().getReadonlyHeaderMap(), + msalRequest.requestContext(), + serviceBundle) + .sslSocketFactory(sslSocketFactory); + + Map params = new HashMap<>(parameters); + mergeClaimsAndCapabilities(params); + request.setQuery(StringHelper.serializeQueryParameters(params)); + return request; + } + + OAuthHttpRequest createOauthHttpRequest( + URL tokenEndpoint, + SSLContext sslContext, + Map parameters) { + if (tokenEndpoint == null) { + throw new MsalClientException("The endpoint URI is not specified", + AuthenticationErrorCode.INVALID_ENDPOINT_URI); + } + + OAuthHttpRequest request = new OAuthHttpRequest( + HttpMethod.POST, + tokenEndpoint, + msalRequest.headers().getReadonlyHeaderMap(), + msalRequest.requestContext(), + serviceBundle) + .sslContext(sslContext); + + Map params = new HashMap<>(parameters); + mergeClaimsAndCapabilities(params); + request.setQuery(StringHelper.serializeQueryParameters(params)); + return request; + } + + private void mergeClaimsAndCapabilities(Map params) { + String claims = params.get("claims"); + if (msalRequest.application() instanceof AbstractClientApplicationBase + && ((AbstractClientApplicationBase) msalRequest.application()).clientCapabilities() != null) { + claims = mergeClaims( + claims, + ((AbstractClientApplicationBase) msalRequest.application()).clientCapabilities()); + } else if (msalRequest.application() instanceof ManagedIdentityApplication) { + List capabilities = + ((ManagedIdentityApplication) msalRequest.application()).getClientCapabilities(); + if (capabilities != null && !capabilities.isEmpty()) { + claims = mergeClaims( + claims, + JsonHelper.formCapabilitiesJson(new HashSet<>(capabilities))); + } + } + + ClaimsRequest requestClaims = msalRequest.requestContext().apiParameters().claims(); + if (requestClaims != null) { + claims = mergeClaims(claims, requestClaims.formatAsJSONString()); + } + if (!StringHelper.isBlank(claims)) { + params.put("claims", claims); + } + } + + private static String mergeClaims(String first, String second) { + if (StringHelper.isBlank(first)) { + return second; + } + if (StringHelper.isBlank(second)) { + return first; + } + return JsonHelper.mergeJSONString(first, second); + } + private void addQueryParameters(OAuthHttpRequest oauthHttpRequest) { Map queryParameters = StringHelper.parseQueryParameters(oauthHttpRequest.query); String clientID = msalRequest.application().clientId(); @@ -240,6 +349,7 @@ private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(Htt refreshOn(response.getRefreshIn() > 0 ? currTimestampSec + response.getRefreshIn() : 0). accountCacheEntity(accountCacheEntity). scopes(response.getScope()). + tokenType(response.tokenType()). metadata(AuthenticationResultMetadata.builder() .tokenSource(TokenSource.IDENTITY_PROVIDER) .refreshOn(response.getRefreshIn() > 0 ? currTimestampSec + response.getRefreshIn() : 0) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java index b314bb77..8a543081 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java @@ -16,11 +16,13 @@ class TokenResponse { private String accessToken; private String idToken; private String refreshToken; + private String tokenType; TokenResponse(Map jsonMap) { this.accessToken = jsonMap.get("access_token"); this.idToken = jsonMap.get("id_token"); this.refreshToken = jsonMap.get("refresh_token"); + this.tokenType = jsonMap.get("token_type"); this.scope = jsonMap.get("scope"); this.clientInfo = jsonMap.get("client_info"); this.expiresIn = StringHelper.isNullOrBlank(jsonMap.get("expires_in")) ? 0 : Long.parseLong(jsonMap.get("expires_in")); @@ -73,4 +75,8 @@ public String idToken() { public String refreshToken() { return refreshToken; } + + String tokenType() { + return tokenType; + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplierMtlsTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplierMtlsTest.java new file mode 100644 index 00000000..765bcbd5 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenByManagedIdentitySupplierMtlsTest.java @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import java.security.cert.X509Certificate; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AcquireTokenByManagedIdentitySupplierMtlsTest { + + @Test + void providerRuntimeFailureIsNormalized() { + RuntimeException providerFailure = + new RuntimeException("extension-specific failure"); + IManagedIdentityMtlsProvider provider = request -> { + throw providerFailure; + }; + + MsalClientException exception = assertThrows( + MsalClientException.class, + () -> AcquireTokenByManagedIdentitySupplier + .getMtlsProviderBinding(provider, request())); + + assertEquals(MsalError.MANAGED_IDENTITY_MTLS_REQUEST_FAILED, + exception.errorCode()); + assertSame(providerFailure, exception.getCause()); + } + + @Test + void providerMsalFailureIsPreserved() { + MsalClientException providerFailure = new MsalClientException( + "known failure", + MsalError.MANAGED_IDENTITY_MTLS_PROVIDER_UNAVAILABLE); + IManagedIdentityMtlsProvider provider = request -> { + throw providerFailure; + }; + + assertSame(providerFailure, assertThrows( + MsalClientException.class, + () -> AcquireTokenByManagedIdentitySupplier + .getMtlsProviderBinding(provider, request()))); + } + + @Test + void minimumBindingStrengthFailsClosed() { + ManagedIdentityMtlsBinding softwareBinding = + binding(MtlsBindingStrength.SOFTWARE); + + MsalClientException exception = assertThrows( + MsalClientException.class, + () -> AcquireTokenByManagedIdentitySupplier + .validateMinimumBindingStrength( + softwareBinding, + MtlsBindingStrength.KEY_GUARD)); + + assertEquals( + MsalError.MANAGED_IDENTITY_MTLS_MINIMUM_STRENGTH_NOT_MET, + exception.errorCode()); + } + + @Test + void minimumBindingStrengthAcceptsStrongerBinding() { + AcquireTokenByManagedIdentitySupplier.validateMinimumBindingStrength( + binding(MtlsBindingStrength.KEY_GUARD), + MtlsBindingStrength.SOFTWARE); + } + + @Test + void imdsCallbackUsesImdsRetryPolicyForHttp410() throws Exception { + DefaultHttpClient httpClient = mock(DefaultHttpClient.class); + HttpResponse gone = response(HttpStatus.HTTP_GONE, "updating"); + HttpResponse success = response(HttpStatus.HTTP_OK, "{}"); + when(httpClient.send(any(HttpRequest.class))) + .thenReturn(gone, success); + + IMDSRetryPolicy.setRetryDelayMs(0); + try { + ManagedIdentityApplication application = + ManagedIdentityApplication + .builder(ManagedIdentityId.systemAssigned()) + .httpClient(httpClient) + .build(); + ManagedIdentityParameters parameters = + ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .build(); + RequestContext context = new RequestContext( + application, + PublicApi.ACQUIRE_TOKEN_BY_SYSTEM_ASSIGNED_MANAGED_IDENTITY, + parameters); + HttpHelper imdsHelper = + new HttpHelper(httpClient, new IMDSRetryPolicy()); + ServiceBundle serviceBundle = new ServiceBundle( + null, + new TelemetryManager(null, false), + imdsHelper); + IManagedIdentityMtlsHttpClient callback = + AcquireTokenByManagedIdentitySupplier + .createMtlsProviderHttpClient( + imdsHelper, + serviceBundle, + context); + + ManagedIdentityMtlsHttpResponse result = callback.execute( + new ManagedIdentityMtlsHttpRequest( + "GET", + "http://169.254.169.254/metadata/identity/getplatformmetadata", + Collections.singletonMap("Metadata", "true"), + null)); + + assertEquals(HttpStatus.HTTP_OK, result.statusCode()); + verify(httpClient, + org.mockito.Mockito.times(2)).send(any(HttpRequest.class)); + } finally { + IMDSRetryPolicy.resetToDefaults(); + } + } + + private static ManagedIdentityMtlsRequest request() { + return new ManagedIdentityMtlsRequest( + null, + null, + "binding", + "correlation", + httpRequest -> new ManagedIdentityMtlsHttpResponse( + HttpStatus.HTTP_OK, + "{}", + Collections.emptyMap())); + } + + private static ManagedIdentityMtlsBinding binding( + MtlsBindingStrength strength) { + IMtlsBindingContext context = new IMtlsBindingContext() { + @Override + public MtlsBindingStrength bindingStrength() { + return strength; + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public javax.net.ssl.X509ExtendedKeyManager keyManager() { + return null; + } + + @Override + public X509Certificate bindingCertificate() { + return null; + } + + @Override + public String keyId() { + return "key"; + } + }; + return new ManagedIdentityMtlsBinding( + context, + "client", + "https://login.example/token"); + } + + private static HttpResponse response(int status, String body) { + HttpResponse response = new HttpResponse(); + response.statusCode(status); + response.body(body); + return response; + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultMtlsTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultMtlsTest.java new file mode 100644 index 00000000..9d6ffefc --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultMtlsTest.java @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import java.io.*; +import java.security.cert.X509Certificate; + +import static org.junit.jupiter.api.Assertions.*; + +class AuthenticationResultMtlsTest { + + @Test + void deserializedMtlsResultFailsClosedWithoutBindingContext() throws Exception { + IMtlsBindingContext context = new TestBindingContext(); + AuthenticationResult result = AuthenticationResult.builder() + .accessToken("secret") + .expiresOn(System.currentTimeMillis() / 1000 + 3600) + .tokenType("mtls_pop") + .mtlsBindingContext(context) + .build(); + + byte[] serialized; + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ObjectOutputStream output = new ObjectOutputStream(bytes)) { + output.writeObject(result); + serialized = bytes.toByteArray(); + } + try (ObjectInputStream input = new ObjectInputStream( + new ByteArrayInputStream(serialized))) { + InvalidObjectException exception = assertThrows( + InvalidObjectException.class, + input::readObject); + assertTrue(exception.getMessage().contains("process-local binding context")); + } + } + + @Test + void equalityIncludesTokenTypeButExcludesLiveBindingContext() { + AuthenticationResult bearer = result("Bearer", null); + AuthenticationResult first = result("mtls_pop", new TestBindingContext()); + AuthenticationResult second = + first.withMtlsBindingContext(new TestBindingContext()); + + assertNotEquals(bearer, first); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertEquals(MtlsBindingStrength.NONE, + bearer.mtlsBindingStrength()); + assertEquals(MtlsBindingStrength.KEY_GUARD, + first.mtlsBindingStrength()); + } + + private static AuthenticationResult result( + String tokenType, + IMtlsBindingContext context) { + return AuthenticationResult.builder() + .accessToken("secret") + .expiresOn(123) + .tokenType(tokenType) + .isPopAuthorization(context == null ? null : Boolean.TRUE) + .mtlsBindingContext(context) + .build(); + } + + private static final class TestBindingContext implements IMtlsBindingContext { + @Override + public MtlsBindingStrength bindingStrength() { + return MtlsBindingStrength.KEY_GUARD; + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public javax.net.ssl.X509ExtendedKeyManager keyManager() { + return null; + } + + @Override + public X509Certificate bindingCertificate() { + return null; + } + + @Override + public String keyId() { + return "key"; + } + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DefaultHttpClientMtlsTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DefaultHttpClientMtlsTest.java new file mode 100644 index 00000000..a15871f0 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DefaultHttpClientMtlsTest.java @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; + +class DefaultHttpClientMtlsTest { + + @Test + void requestSpecificMtlsDisablesRedirects() throws Exception { + DefaultHttpClient client = + new DefaultHttpClient(null, null, null, null); + + HttpsURLConnection connection = (HttpsURLConnection) + client.openConnection( + new URL("https://localhost/token"), + mock(SSLSocketFactory.class)); + + assertFalse(connection.getInstanceFollowRedirects()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityCapabilitiesTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityCapabilitiesTest.java new file mode 100644 index 00000000..bc94976c --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityCapabilitiesTest.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ManagedIdentityCapabilitiesTest { + + @Test + void keyGuardCapabilityIsReportedAsSupported() { + ManagedIdentityCapabilities capabilities = + new ManagedIdentityCapabilities( + ManagedIdentitySourceType.IMDS, + MtlsBindingStrength.KEY_GUARD, + null); + + assertEquals(ManagedIdentitySourceType.IMDS, + capabilities.source()); + assertEquals(MtlsBindingStrength.KEY_GUARD, + capabilities.maxSupportedBindingStrength()); + assertTrue(capabilities.isMtlsPopSupportedByHost()); + assertNull(capabilities.errorReason()); + } + + @Test + void unavailableCapabilityIncludesReason() { + ManagedIdentityCapabilities capabilities = + new ManagedIdentityCapabilities( + ManagedIdentitySourceType.DEFAULT_TO_IMDS, + MtlsBindingStrength.NONE, + "IMDS v2 unavailable"); + + assertFalse(capabilities.isMtlsPopSupportedByHost()); + assertEquals("IMDS v2 unavailable", + capabilities.errorReason()); + } + + @Test + void providerDefaultDoesNotOverclaimSupport() { + IManagedIdentityMtlsProvider provider = request -> null; + + assertEquals(MtlsBindingStrength.NONE, + provider.getMaxSupportedBindingStrength(null)); + } + + @Test + void applicationRetriesUnavailableImdsDiscovery() + throws Exception { + ManagedIdentityApplication application = ManagedIdentityApplication + .builder(ManagedIdentityId.systemAssigned()) + .build(); + + CompletableFuture first = + application.getManagedIdentityCapabilities(); + + ManagedIdentityCapabilities capabilities = first.get(); + assertEquals(MtlsBindingStrength.NONE, + capabilities.maxSupportedBindingStrength()); + assertFalse(capabilities.isMtlsPopSupportedByHost()); + assertTrue(capabilities.errorReason().contains( + "msal4j-mtls-extensions")); + + CompletableFuture retry = + application.getManagedIdentityCapabilities(); + assertTrue(retry != first); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsBindingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsBindingTest.java new file mode 100644 index 00000000..70a1f009 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsBindingTest.java @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import java.security.cert.X509Certificate; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ManagedIdentityMtlsBindingTest { + + @Test + void tokenEndpointMustUseHttps() { + IMtlsBindingContext context = new IMtlsBindingContext() { + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public javax.net.ssl.X509ExtendedKeyManager keyManager() { + return null; + } + + @Override + public X509Certificate bindingCertificate() { + return null; + } + + @Override + public String keyId() { + return "key"; + } + }; + + assertThrows(IllegalArgumentException.class, + () -> new ManagedIdentityMtlsBinding( + context, "client", "http://login.example/token")); + assertThrows(IllegalArgumentException.class, + () -> new ManagedIdentityMtlsBinding( + context, "client", "not-a-url")); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsParametersTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsParametersTest.java new file mode 100644 index 00000000..b4263d93 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsParametersTest.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ManagedIdentityMtlsParametersTest { + + @Test + void attestationRequiresMtlsButMtlsCanBeRequestedAlone() { + assertDoesNotThrow( + () -> ManagedIdentityParameters.builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .build()); + assertThrows(IllegalArgumentException.class, + () -> ManagedIdentityParameters.builder("https://vault.azure.net") + .withAttestationSupport() + .build()); + } + + @Test + void mtlsOptionsCarryMinimumBindingStrength() { + ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession( + MtlsPopOptions.builder() + .minimumBindingStrength( + MtlsBindingStrength.KEY_GUARD) + .build()) + .build(); + + assertEquals(MtlsBindingStrength.KEY_GUARD, + parameters.minimumBindingStrength()); + } + + @Test + void bearerAndMtlsCachePartitionsCannotCollide() { + ManagedIdentityParameters bearer = + ManagedIdentityParameters.builder("https://vault.azure.net").build(); + ManagedIdentityParameters mtls = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .withAttestationSupport() + .build(); + + assertEquals("", bearer.computeExtCacheKeyHash()); + assertFalse(mtls.computeMtlsExtCacheKeyHash("certificate-a").isEmpty()); + assertNotEquals(bearer.computeExtCacheKeyHash(), + mtls.computeMtlsExtCacheKeyHash("certificate-a")); + } + + @Test + void renewedCertificateCreatesNewCachePartition() { + ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .withAttestationSupport() + .build(); + String first = parameters.computeMtlsExtCacheKeyHash("certificate-a"); + + assertNotEquals(first, + parameters.computeMtlsExtCacheKeyHash("certificate-b")); + } + + @Test + void attestationModeCreatesDistinctCachePartition() { + ManagedIdentityParameters unattested = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .build(); + ManagedIdentityParameters attested = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .withAttestationSupport() + .build(); + + assertNotEquals( + unattested.computeMtlsExtCacheKeyHash("certificate-a"), + attested.computeMtlsExtCacheKeyHash("certificate-a")); + } + + @Test + void minimumStrengthCreatesDistinctCachePartition() { + ManagedIdentityParameters noFloor = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .build(); + ManagedIdentityParameters keyGuardFloor = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession( + MtlsPopOptions.builder() + .minimumBindingStrength( + MtlsBindingStrength.KEY_GUARD) + .build()) + .build(); + + assertNotEquals( + noFloor.computeMtlsExtCacheKeyHash("certificate-a"), + keyGuardFloor.computeMtlsExtCacheKeyHash("certificate-a")); + } + + @Test + void buildersDoNotExposeClaimsOrTokenMaterial() { + ManagedIdentityParameters.ManagedIdentityParametersBuilder builder = + ManagedIdentityParameters.builder("https://vault.azure.net") + .claims("{\"access_token\":\"secret\"}") + .withMtlsProofOfPossession() + .withAttestationSupport(); + + assertFalse(builder.toString().contains("secret")); + } + + @Test + void tokenEndpointMustExplicitlyReturnMtlsPop() { + AuthenticationResult bearer = AuthenticationResult.builder() + .accessToken("token") + .tokenType("Bearer") + .build(); + assertThrows(MsalServiceException.class, + () -> AcquireTokenByManagedIdentitySupplier + .validateMtlsTokenResponse(bearer)); + + AuthenticationResult missingTokenType = AuthenticationResult.builder() + .accessToken("token") + .build(); + assertThrows(MsalServiceException.class, + () -> AcquireTokenByManagedIdentitySupplier + .validateMtlsTokenResponse(missingTokenType)); + + AuthenticationResult mtlsPop = AuthenticationResult.builder() + .accessToken("token") + .tokenType("mtls_pop") + .build(); + assertDoesNotThrow(() -> AcquireTokenByManagedIdentitySupplier + .validateMtlsTokenResponse(mtlsPop)); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsProviderLoaderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsProviderLoaderTest.java new file mode 100644 index 00000000..355a28f6 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityMtlsProviderLoaderTest.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ManagedIdentityMtlsProviderLoaderTest { + + @Test + void missingOptionalProviderFailsClosed() { + MsalClientException exception = assertThrows( + MsalClientException.class, + ManagedIdentityMtlsProviderLoader::load); + + assertEquals(MsalError.MANAGED_IDENTITY_MTLS_PROVIDER_UNAVAILABLE, + exception.errorCode()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java index ac743755..c9dca3d3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -26,12 +27,88 @@ import java.util.Base64; import java.util.Collections; import java.util.HashMap; +import java.util.Map; import java.util.concurrent.ExecutionException; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; @ExtendWith(MockitoExtension.class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) class TokenRequestExecutorTest { + @Test + void managedIdentityMtlsRequestUsesNormalClaimsPipelineAndSocketFactory() + throws Exception { + ManagedIdentityApplication app = ManagedIdentityApplication + .builder(ManagedIdentityId.systemAssigned()) + .clientCapabilities(Collections.singletonList("cp1")) + .build(); + ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder("https://vault.azure.net") + .claims("{\"access_token\":{\"custom\":{\"essential\":true}}}") + .withMtlsProofOfPossession() + .build(); + ManagedIdentityRequest managedIdentityRequest = + new ManagedIdentityRequest( + app, + new RequestContext( + app, + PublicApi.ACQUIRE_TOKEN_BY_SYSTEM_ASSIGNED_MANAGED_IDENTITY, + parameters)); + TokenRequestExecutor executor = new TokenRequestExecutor( + new AADAuthority(new URL(TestConstants.ORGANIZATIONS_AUTHORITY)), + managedIdentityRequest, + app.serviceBundle()); + SSLSocketFactory socketFactory = mock(SSLSocketFactory.class); + Map body = new HashMap<>(); + body.put("grant_type", "client_credentials"); + body.put("token_type", "mtls_pop"); + + OAuthHttpRequest request = executor.createOauthHttpRequest( + new URL("https://login.example/tenant/oauth2/v2.0/token"), + socketFactory, + body); + Map query = StringHelper.parseQueryParameters(request.query); + + assertEquals(socketFactory, request.sslSocketFactory()); + assertEquals("mtls_pop", query.get("token_type")); + assertTrue(query.get("claims").contains("\"xms_cc\"")); + assertTrue(query.get("claims").contains("\"custom\"")); + } + + @Test + void managedIdentityMtlsRequestExposesSslContextForAsyncClients() + throws Exception { + ManagedIdentityApplication app = ManagedIdentityApplication + .builder(ManagedIdentityId.systemAssigned()) + .build(); + ManagedIdentityParameters parameters = ManagedIdentityParameters + .builder("https://vault.azure.net") + .withMtlsProofOfPossession() + .build(); + ManagedIdentityRequest managedIdentityRequest = + new ManagedIdentityRequest( + app, + new RequestContext( + app, + PublicApi.ACQUIRE_TOKEN_BY_SYSTEM_ASSIGNED_MANAGED_IDENTITY, + parameters)); + TokenRequestExecutor executor = new TokenRequestExecutor( + new AADAuthority(new URL(TestConstants.ORGANIZATIONS_AUTHORITY)), + managedIdentityRequest, + app.serviceBundle()); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, null); + + OAuthHttpRequest request = executor.createOauthHttpRequest( + new URL("https://login.example/tenant/oauth2/v2.0/token"), + sslContext, + Collections.singletonMap("token_type", "mtls_pop")); + + assertSame(sslContext, request.sslContext()); + assertNotNull(request.sslSocketFactory()); + } + @Test void executeOAuthRequest_SCBadRequestErrorInvalidGrant_InteractionRequiredException() throws MsalException, diff --git a/pom.xml b/pom.xml index 373e77e7..d457aed3 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,16 @@ pom msal4j-sdk + msal4j-mtls-extensions msal4j-brokers msal4j-persistence-extension + + + e2e + + msal4j-mtls-extensions-e2e + + + diff --git a/run-java-msi-v2-mtls-devapp.ps1 b/run-java-msi-v2-mtls-devapp.ps1 new file mode 100644 index 00000000..d620e1dd --- /dev/null +++ b/run-java-msi-v2-mtls-devapp.ps1 @@ -0,0 +1,88 @@ +[CmdletBinding()] +param( + [string]$Maven, + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" +$repoRoot = $PSScriptRoot + +function Assert-EnvironmentVariable { + param([string]$Name) + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($Name))) { + throw "Required environment variable is missing: $Name" + } +} + +if (-not $IsWindows -and $PSVersionTable.PSEdition -eq "Core") { + throw "Managed Identity v2 KeyGuard mTLS PoP requires Windows." +} + +$tokenOnly = [bool]::Parse( + $(if ([string]::IsNullOrWhiteSpace($env:MSAL_JAVA_MTLS_TOKEN_ONLY)) { + "false" + } else { + $env:MSAL_JAVA_MTLS_TOKEN_ONLY + })) +if (-not $tokenOnly) { + Assert-EnvironmentVariable "MSAL_JAVA_MTLS_AKV_URL" + Assert-EnvironmentVariable "MSAL_JAVA_MTLS_AKV_SECRET_NAME" +} + +$java = Get-Command java -ErrorAction SilentlyContinue +if ($null -eq $java) { + throw "java.exe was not found on PATH." +} + +$tpm = Get-Tpm +if (-not $tpm.TpmPresent -or -not $tpm.TpmReady) { + throw "A present and ready TPM is required." +} + +try { + if (-not (Confirm-SecureBootUEFI)) { + throw "Secure Boot is not enabled." + } +} catch [System.PlatformNotSupportedException] { + throw "Secure Boot status could not be verified on this platform." +} + +if (-not $SkipBuild) { + if ([string]::IsNullOrWhiteSpace($Maven)) { + $mavenCommand = Get-Command mvn.cmd, mvn -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $mavenCommand) { + $Maven = $mavenCommand.Source + } + } + if ([string]::IsNullOrWhiteSpace($Maven) -or + -not (Test-Path -LiteralPath $Maven -PathType Leaf)) { + throw "Maven was not found on PATH. Pass -Maven with the path to mvn.cmd." + } + Push-Location $repoRoot + try { + & $Maven -q -pl msal4j-mtls-extensions-e2e -am ` + '-Pe2e' '-DskipTests' '-Dmaven.javadoc.skip=true' package + if ($LASTEXITCODE -ne 0) { + throw "Maven build failed with exit code $LASTEXITCODE." + } + } finally { + Pop-Location + } +} + +$jar = Get-ChildItem ` + (Join-Path $repoRoot "msal4j-mtls-extensions-e2e\target") ` + -Filter "*-e2e.jar" | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 +if ($null -eq $jar) { + throw "The manual validation JAR was not found. Run without -SkipBuild." +} + +Write-Host "Attestation DLL: bundled Microsoft.Azure.Security.KeyGuardAttestation 1.1.5" +Write-Host "Validation JAR: $($jar.FullName)" +& $java.Source -jar $jar.FullName +if ($LASTEXITCODE -ne 0) { + throw "Managed Identity v2 mTLS PoP validation failed with exit code $LASTEXITCODE." +}