Azure / Azure/azure-sdk-for-python

CryptographyClient: local crypto operations ignore key `enabled` flag, bypassing server's access control

Open
#45,342 3 comments 0 reactions 0 assignees View on GitHub
customer-reported KeyVault needs-team-attention question Service Attention
Dominant language
Python
Stars
5.6k
Forks
3.4k
Avg merge
1d 21h
Merged PRs (30d)
193

Description

## Summary

`CryptographyClient` performs local crypto operations (decrypt, sign, unwrap) on keys where `enabled=False` without raising an error. The Azure Key Vault **server** blocks exactly these three operations with `403 Forbidden: Operation X is not allowed on a disabled key (KeyDisabled)`. The SDK silently bypasses this control when performing local crypto.

This means an administrator who disables a key to revoke its use will find that applications with cached key material continue to decrypt, sign, and unwrap locally — the security action is bypassed without warning.

## Observed Behavior

| Operation | Server (disabled key) | SDK Local (disabled key) | Gap? |
|-----------|----------------------|--------------------------|------|
| encrypt | ALLOWED | ALLOWED | No |
| decrypt | **BLOCKED (403)** | **ALLOWED** | ✅ YES |
| sign | **BLOCKED (403)** | **ALLOWED** | ✅ YES |
| verify | ALLOWED | ALLOWED | No |
| wrap_key | ALLOWED | ALLOWED | No |
| unwrap_key| **BLOCKED (403)** | **ALLOWED** | ✅ YES |

Server error (confirmed via live Azure subscription, `azure-keyvault-keys==4.11.0`):
```
HttpResponseError: (Forbidden) Operation decrypt is not allowed on a disabled key.
Inner error: {"code": "KeyDisabled"}
```

SDK allows the same operation without error when key material is available locally.

## Root Cause

`CryptographyClient.__init__` reads `not_before` and `expires_on` from `KeyVaultKey.properties` to enforce time-based validity — but **never reads `enabled`**:

**`sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/crypto/_client.py` (lines ~119–155):**
```python
# Time validity is stored and later checked:
self._not_before = key.properties.not_before
self._expires_on = key.properties.expires_on

# But `enabled` is never read or stored here.
```

**`sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/crypto/_key_validity.py` (lines 9–16):**
```python
def raise_if_time_invalid(not_before, expires_on):
# Checks not_before and expires_on — no `enabled` parameter exists.
```

The inconsistency: `not_before`/`expires_on` are enforced locally, but `enabled` is not — despite the server treating `enabled=False` as a harder control (explicit admin revocation, not time-based expiry).

## Reproduction

```python
from azure.keyvault.keys import KeyVaultKey, JsonWebKey, KeyOperation
from azure.keyvault.keys.crypto import CryptographyClient, EncryptionAlgorithm, SignatureAlgorithm
from cryptography.hazmat.primitives.asymmetric import rsa
import hashlib

# Generate an RSA key
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv = private_key.private_numbers()
pub = priv.public_numbers
to_bytes = lambda n, l=None: n.to_bytes(l or ((n.bit_length() + 7) // 8), 'big')

jwk = JsonWebKey(
kid="https://vault.example.com/keys/disabled-key/1",
kty="RSA",
key_ops=["encrypt", "decrypt", "sign", "verify", "wrapKey", "unwrapKey"],
n=to_bytes(pub.n, 256), e=to_bytes(pub.e),
d=to_bytes(priv.d, 256), p=to_bytes(priv.p, 128), q=to_bytes(priv.q, 128),
dp=to_bytes(priv.dmp1, 128), dq=to_bytes(priv.dmq1, 128), qi=to_bytes(priv.iqmp, 128),
)

kvk = KeyVaultKey(key_id=jwk.kid, jwk=vars(jwk))

# Set enabled=False — simulating a key disabled by an admin
kvk.properties._attributes = type('A', (), {
'enabled': False, 'not_before': None, 'expires': None,
'created': None, 'updated': None, 'recoverable_days': None,
'recovery_level': None, 'exportable': None, 'hsm_platform': None,
})()

client = CryptographyClient(kvk, credential=object(), _jwk=True)

print(f"Key enabled: {kvk.properties.enabled}") # False

# All three of these succeed — server would block them with 403 KeyDisabled
enc = client.encrypt(EncryptionAlgorithm.rsa_oaep, b"secret")
dec = client.decrypt(EncryptionAlgorithm.rsa_oaep, enc.ciphertext)
print(f"Decrypt SUCCEEDED on disabled key: {dec.plaintext}") # should not succeed

sig = client.sign(SignatureAlgorithm.rs256, hashlib.sha256(b"data").digest())
print(f"Sign SUCCEEDED on disabled key: {len(sig.signature)} bytes") # should not succeed
```

Also reproducible via `CryptographyClient.from_jwk()` (no `enabled` attribute exists in the JWK path — zero validity checks).

## Expected Behavior

When `key.properties.enabled == False`, `CryptographyClient` should raise an error for the three private-key operations (`decrypt`, `sign`, `unwrap_key`) that the server blocks — consistent with how `not_before` and `expires_on` are already enforced locally.

Suggested fix (matching the pattern already used for time validity):

```python
# In _client.py __init__, alongside not_before/expires_on:
self._enabled = key.properties.enabled # store enabled flag

# In _key_validity.py, add enabled check alongside time checks:
def raise_if_invalid(enabled, not_before, expires_on):
if enabled is False:
raise ValueError("Key is disabled and cannot be used for cryptographic operations")
# existing not_before / expires_on checks...
```

## Environment

- `azure-keyvault-keys`: 4.11.0 (latest)
- `azure-identity`: 1.25.1
- Python: 3.11
- OS: macOS / Linux (platform-independent, local crypto path)

## Additional Context

The Azure.Security.KeyVault.Keys .NET SDK has the same gap (`LocalCryptographyProvider` calls `ThrowIfTimeInvalid` but never checks `Enabled`), suggesting this was a consistent oversight across SDK implementations rather than an intentional design choice.

The `from_jwk()` path has no validity checks at all (no `enabled`, no time), which is a separate but related gap.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.