Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions docs/MSI_V2_API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# MSI v2 — mTLS Proof-of-Possession API Reference

## Overview

MSI v2 enables Managed Identity token acquisition with mTLS Proof-of-Possession
on Windows Azure VMs with Credential Guard (KeyGuard). Tokens are
cryptographically bound to a per-boot hardware-isolated key.

## Requirements

- Windows Azure VM with Credential Guard / KeyGuard enabled
- `AttestationClientLib.dll` available on the system
- Python 3.8+

## Installation

```bash
pip install msal msal-key-attestation requests
```

## Public API

### Token Acquisition

```python
import msal
import requests

client = msal.ManagedIdentityClient(
msal.SystemAssignedManagedIdentity(),
http_client=requests.Session(),
)

result = client.acquire_token_for_client(
resource="https://graph.microsoft.com",
mtls_proof_of_possession=True, # Enable MSI v2 mTLS PoP
with_attestation_support=True, # Require msal-key-attestation
)
```

#### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `resource` | str | Yes | Resource URI (e.g., `https://graph.microsoft.com`) |
| `mtls_proof_of_possession` | bool | No | Enable mTLS PoP token binding |
| `with_attestation_support` | bool | No | Require MAA attestation (needs `msal-key-attestation`) |

#### Return Value

On success, returns a dict:

```python
{
"access_token": "eyJ0eXAi...", # The mTLS-bound access token
"expires_in": 3599, # Token lifetime in seconds
"token_type": "mtls_pop", # Always "mtls_pop" for this flow
"cert_pem": "-----BEGIN CERT...", # PEM certificate (for downstream mTLS)
"cert_der_b64": "MIIC...", # Base64-encoded DER certificate
"cert_thumbprint_sha256": "buc7x...", # Base64url SHA-256 thumbprint
}
```

On failure, raises `MsiV2Error`.

### User-Assigned Managed Identity

```python
# By client_id
client = msal.ManagedIdentityClient(
msal.UserAssignedManagedIdentity(client_id="11111111-..."),
http_client=requests.Session(),
)

# By object_id
client = msal.ManagedIdentityClient(
msal.UserAssignedManagedIdentity(object_id="22222222-..."),
http_client=requests.Session(),
)

# By resource_id
client = msal.ManagedIdentityClient(
msal.UserAssignedManagedIdentity(
resource_id="/subscriptions/.../providers/Microsoft.ManagedIdentity/..."),
http_client=requests.Session(),
)
```

### Downstream mTLS Call

After acquiring the token, use the certificate for downstream API calls.
The `cert_pem` in the result is the same certificate bound to the token's
`cnf.x5t#S256` claim.

> **Note:** Standard Python `requests` cannot present a KeyGuard-bound
> certificate (the private key is non-exportable). Use WinHTTP/SChannel
> for production mTLS calls. The token + cert are provided so you can
> wire them into a native HTTP transport.

```python
# Authorization header format:
headers = {
"Authorization": f"mtls_pop {result['access_token']}"
}
```

### Helper Functions

```python
from msal.msi_v2 import get_cert_thumbprint_sha256, verify_cnf_binding

# Compute base64url SHA-256 thumbprint of a PEM certificate
thumbprint = get_cert_thumbprint_sha256(cert_pem)

# Verify that a JWT's cnf.x5t#S256 matches the certificate
is_bound = verify_cnf_binding(access_token, cert_pem)
```

## Error Handling

```python
from msal import MsiV2Error, ManagedIdentityError

try:
result = client.acquire_token_for_client(...)
except MsiV2Error as e:
# MSI v2 specific failure (no v1 fallback)
print(f"MSI v2 failed: {e}")
except ManagedIdentityError as e:
# General managed identity error
print(f"MI error: {e}")
```

### Error Hierarchy

```
ValueError
└── ManagedIdentityError
└── MsiV2Error
```

### Common Errors

| Error Message | Cause |
|---------------|-------|
| `KeyGuard + mTLS PoP is Windows-only` | Running on non-Windows |
| `with_attestation_support=True requires...` | `msal-key-attestation` not installed |
| `attestation_requires_pop` | `with_attestation_support=True` without `mtls_proof_of_possession=True` |
| `getplatformmetadata missing required fields` | VM doesn't support IMDS v2 |
| `attestationEndpoint missing` | VM doesn't have MAA configured |

## msal-key-attestation Package

Separate pip package providing Windows `AttestationClientLib.dll` bindings.

### API

```python
from msal_key_attestation import create_attestation_provider

provider = create_attestation_provider()
# provider(endpoint, key_handle, client_id, cache_key) -> str (JWT)
```

The provider is automatically discovered by MSAL when
`with_attestation_support=True` is set.

### Environment Variables

| Variable | Description |
|----------|-------------|
| `ATTESTATION_CLIENTLIB_PATH` | Absolute path to `AttestationClientLib.dll` |
| `MSAL_MSI_V2_ATTESTATION_CACHE` | Set to `"0"` to disable MAA token cache |

## Environment Variables (Core)

| Variable | Description |
|----------|-------------|
| `AZURE_POD_IDENTITY_AUTHORITY_HOST` | Override IMDS base URL (default: `http://169.254.169.254`) |
| `MSAL_MSI_V2_KEY_NAME` | Override per-boot key name |

## Flow Diagram

```
1. NCrypt → Create/open KeyGuard RSA key (VBS-isolated, per-boot)
2. IMDS → GET /getplatformmetadata → clientId, tenantId, cuId, attestationEndpoint
3. CSR → Build PKCS#10 (RSA-PSS/SHA256 + cuId OID attribute)
4. Attestation → AttestationClientLib.dll → MAA JWT (proves key is KeyGuard-protected)
5. IMDS → POST /issuecredential {csr, attestation_token} → X.509 certificate
6. Crypt32 → Bind certificate to NCrypt key handle (SChannel-ready)
7. WinHTTP → POST /oauth2/v2.0/token via mTLS → mtls_pop access token
```

## Caching

- **Certificate cache**: In-memory, process-local. Evicts when remaining
lifetime < 24 hours. Keyed by managed identity + attestation mode.
- **MAA token cache** (in `msal-key-attestation`): Refreshes at 90% of
JWT lifetime, 10-second absolute guard before expiry.
131 changes: 131 additions & 0 deletions docs/mTLS-PoP-Architecture-Decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# MSAL Python — mTLS PoP with Non-Exportable Keys: Architecture Decision

## Problem

Python applications on Azure VMs with Credential Guard / KeyGuard need to:
1. Acquire an `mtls_pop` token bound to a non-exportable private key
2. Make downstream API calls presenting that same key via mTLS (TLS client certificate)

The private key lives inside Windows VBS (Virtualization-Based Security) and **cannot be exported as raw bytes**. This is by design — it's the entire security model of KeyGuard.

## Why Python Has a Gap

Every mainstream Python HTTP library uses OpenSSL for TLS:

| Library | TLS Backend | Can use non-exportable keys? |
|---------|-------------|------------------------------|
| `requests` / `urllib3` | OpenSSL | ❌ Requires `key_bytes` |
| `httpx` | OpenSSL | ❌ Requires `key_bytes` |
| `aiohttp` | OpenSSL | ❌ Requires `key_bytes` |
| `ssl` stdlib | OpenSSL | ❌ `SSLContext.load_cert_chain(keyfile=...)` needs a file/PEM |

OpenSSL needs the private key as raw bytes (PEM/DER) to perform the TLS `CertificateVerify` signature. A non-exportable CNG key handle (`NCRYPT_KEY_HANDLE`) cannot provide this.

**.NET does not have this problem** — `HttpClientHandler` uses Windows SChannel natively, which accepts `X509Certificate2` objects backed by non-exportable keys. The OS TLS stack performs the signature internally.

## Research: Could We Use an OpenSSL CNG Provider?

### RTI OpenSSL CNG Engine (2021)

- **Repo:** https://github.com/rticommunity/openssl-cng-engine
- **Docs:** https://openssl-cng-engine.readthedocs.io/en/latest/about.html
- **Author:** RTI (Real-Time Innovations), a DDS middleware company
- **License:** Apache 2.0

RTI built an OpenSSL **ENGINE** that bridges OpenSSL → Windows CNG. It provides:
- `engine-bcrypt.dll` — Crypto primitives (AES, RSA, ECDSA)
- `engine-ncrypt.dll` — Key Store access (reads certs/keys from Windows cert store)

### Why It Doesn't Work for Us

| Issue | Detail |
|-------|--------|
| **OpenSSL 1.1.1 only** | Python 3.12+ ships OpenSSL 3.x. The ENGINE API was deprecated. |
| **Not ported to OpenSSL 3.x PROVIDER** | OpenSSL 3.x replaced ENGINEs with PROVIDERs (completely different API). No port exists. |
| **No KeyGuard/VBS testing** | Built for regular CNG keys, not VBS-isolated keys. |
| **Python `ssl` module** | Doesn't expose `ENGINE_load()` or `OSSL_PROVIDER_load()` to user code. |
| **RSA-PSS limitation** | ENGINE API can't support RSA-PSS (needed for modern TLS 1.3). |
| **Unmaintained** | Last meaningful activity ~2021. |

### OpenSSL Mailing List Confirmation (July 2021)

From David von Oheimb (OpenSSL project):
> "Porting this to the new OpenSSL crypto **provider** interface will likely lift the limitation regarding RSA-PSS support, which lacks just due to the engine interface."

Source: https://mta.openssl.org/pipermail/openssl-users/2021-July/013944.html

**No one has built this provider.** Not RTI, not Microsoft, not the OpenSSL community.

## Our Solution: WinHTTP/SChannel via `msal-schannel-transport`

Instead of trying to make OpenSSL work with non-exportable keys, we bypass OpenSSL entirely and use Windows' native TLS stack (SChannel) via WinHTTP — the same approach .NET uses internally.

### Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│ App Developer Code │
│ │
│ result = client.acquire_token_for_client( │
│ resource="https://vault.azure.net", │
│ mtls_proof_of_possession=True, │
│ with_attestation_support=True, │
│ ) │
│ │
│ binding_cert = result["binding_certificate"] # WindowsCert │
│ auth_header = f"{result['token_type']} {result['access_token']}│
│ │
│ with SchannelSession(client_certificate=binding_cert) as s: │
│ response = s.get(url, headers={"Authorization": auth_header})│
└──────────────┬──────────────────────────────────┬────────────────┘
│ │
┌──────────▼──────────┐ ┌───────────▼────────────┐
│ msal (pip package) │ │ msal-schannel-transport │
│ │ │ (pip package) │
│ • Token acquisition │ │ • WinHTTP/SChannel │
│ • KeyGuard key mgmt │ │ • Uses NCRYPT_KEY_HANDLE│
│ • Attestation (MAA) │ │ • No OpenSSL dependency │
│ • Returns cert handle│ │ • mTLS with non-export │
└──────────────────────┘ └─────────────────────────┘
```

### Why Separate Packages?

| Principle | Implementation |
|-----------|---------------|
| MSAL only acquires tokens | `msal` never makes downstream calls |
| App developer owns HTTP | `msal-schannel-transport` is a helper, not MSAL |
| Same pattern as .NET | .NET: MSAL → HttpClient. Python: MSAL → SchannelSession |
| Clear dependency boundary | Teams that don't need downstream mTLS don't install it |

### Comparison with Other Languages

| Language | Token Library | Downstream mTLS Transport | Notes |
|----------|--------------|---------------------------|-------|
| **.NET** | MSAL.NET | `HttpClientHandler` (built-in) | SChannel native, just works |
| **Go** | azure-sdk-for-go | `crypto/tls` + CNG bridge | Go has pluggable TLS |
| **Rust** | azure-sdk-for-rust | `schannel` crate | Native Windows TLS |
| **Python** | MSAL Python | `msal-schannel-transport` (ours) | OpenSSL can't, so we provide it |

## E2E Proof (June 2026)

Tested on `MSIV2` Azure VM (Windows, Credential Guard enabled):

```
✓ Token acquired: mtls_pop, 86399s expiry
✓ Binding certificate: WindowsCertificate (non-exportable, KeyGuard-backed)
✓ cnf.x5t#S256 binding: MATCH
✓ Downstream mTLS call: HTTP 200 from tokenbinding.vault.azure.net
Secret value returned: "secretme"
```

## Future Alternatives (if landscape changes)

| If this happens... | We could... |
|---|---|
| Microsoft ships OpenSSL 3.x CNG Provider | Use `requests` normally with provider loaded |
| Python `ssl` exposes `OSSL_PROVIDER_load()` | Load CNG provider from Python |
| Python adds native SChannel support | Use stdlib directly |
| RTI ports to OpenSSL 3.x + supports KeyGuard | Evaluate as alternative |

**None of these exist today.** Our WinHTTP approach is the only working solution for Python + non-exportable keys + mTLS.
21 changes: 21 additions & 0 deletions msal-key-attestation/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) Microsoft Corporation.

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.
3 changes: 3 additions & 0 deletions msal-key-attestation/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
include LICENSE
include README.md
recursive-exclude tests *
Loading
Loading