Files
2026-07-13 13:22:34 +08:00

651 lines
24 KiB
Python

import json
import logging
import os
from dataclasses import dataclass
from typing import Any
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
# KEK (Key Encryption Key) environment variables for envelope encryption
# These are defined here to avoid importing mlflow.server.constants (which triggers Flask import)
# and to keep this crypto module self-contained for the skinny client.
#
# SECURITY: Server-admin-only credentials. NEVER pass via CLI (visible in ps/logs).
# Set via environment variable or .env file. Users do NOT need this - only server admins.
# Must be high-entropy (32+ characters) from a secrets manager.
CRYPTO_KEK_PASSPHRASE_ENV_VAR = "MLFLOW_CRYPTO_KEK_PASSPHRASE"
CRYPTO_KEK_VERSION_ENV_VAR = "MLFLOW_CRYPTO_KEK_VERSION"
# Default passphrase used when MLFLOW_CRYPTO_KEK_PASSPHRASE is not set.
# This enables the gateway to work out-of-the-box for development and testing.
#
# SECURITY WARNING: Using the default passphrase means all MLflow installations share
# the same encryption key. This is acceptable for development/testing but NOT for production.
# For production deployments, always set MLFLOW_CRYPTO_KEK_PASSPHRASE to a unique,
# high-entropy value from your secrets manager.
#
# If secrets were encrypted with one passphrase and the server is restarted with a different
# passphrase (or the default), decryption will fail. The error message will indicate this.
DEFAULT_KEK_PASSPHRASE = "mlflow-default-kek-passphrase-for-development-only"
_logger = logging.getLogger(__name__)
# Application-level salt for KEK derivation (intentionally fixed, not per-password)
#
# NB: We use a fixed salt because our use case is KEY DERIVATION, not password storage.
# The passphrase is a server-side admin credential (not user passwords), which is in control
# by the server admin.
# Another reason for a fixed salt is because we need a fixed and reliable, deterministic KEK
# derivation (same passphrase always produces same KEK) across server restarts.
# Storing a per-deployment random salt would add complexity without significant security benefit
# because the passphrase is not user-chosen and is not stored in a database.
# This changes the threat model to server compromise, rather than encrypted secrets decryption
# brute-force attacks.
#
# This is acceptable because:
# 1. The passphrase is stored server-side in environment variables (not transmitted to users)
# 2. We use 600,000 PBKDF2 iterations to prevent brute-force attacks (see below for links)
# 3. Admins should use high-entropy passphrases (32+ characters from secrets manager)
# 4. The salt prevents pre-computation attacks across different algorithms/purposes
#
# For comparison, this is similar to how TLS derives keys from master secrets (used in HTTPS)
#
# NB: For password storage use cases (which this is NOT), see OWASP guidance on random salts:
# https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
# For PBKDF2 salt requirements, see:
# https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#cryptography.hazmat.primitives.kdf.pbkdf2.PBKDF2HMAC
MLFLOW_KEK_SALT = b"mlflow-secrets-kek-v1-2025"
# OWASP 2023 recommendation for PBKDF2-HMAC-SHA256 is 600,000 iterations
# This provides strong protection against brute-force attacks on the KEK passphrase
#
# NB: See OWASP Password Storage Cheat Sheet for iteration count recommendations:
# https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
# "PBKDF2-HMAC-SHA256: 600,000 iterations"
PBKDF2_ITERATIONS = 600_000
# AES-256 key length in bytes
#
# NB: NIST FIPS 197 specifies AES-256 uses 32-byte (256-bit) keys:
# https://csrc.nist.gov/pubs/fips/197/final
AES_256_KEY_LENGTH = 32
# AES-GCM nonce length in bytes (96 bits recommended from NIST)
#
# NB: NIST SP 800-38D recommends 96-bit (12-byte) nonces for GCM:
# https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
# "The performance of GCM can be optimized by using IVs of length 96 bits"
#
# Also see cryptography.io AESGCM documentation:
# https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM
GCM_NONCE_LENGTH = 12
@dataclass(frozen=True)
class AESGCMResult:
"""
Result of AES-GCM encryption operation.
Attributes:
nonce: 12-byte random nonce used for encryption
ciphertext: Encrypted data with 16-byte authentication tag appended
"""
nonce: bytes
ciphertext: bytes
@dataclass(frozen=True)
class EncryptedSecret:
"""
Result of secret encryption using envelope encryption.
Attributes:
encrypted_value: Full encrypted secret (nonce + ciphertext + tag)
wrapped_dek: Encrypted DEK wrapped with KEK (nonce + ciphertext + tag)
kek_version: Version of the KEK used to wrap the DEK
Both encrypted_value and wrapped_dek are ready for database storage as bytes.
"""
encrypted_value: bytes
wrapped_dek: bytes
kek_version: int
@dataclass(frozen=True)
class RotatedSecret:
"""
Result of KEK rotation for a secret.
Attributes:
encrypted_value: Unchanged encrypted secret (same DEK, same ciphertext)
wrapped_dek: DEK re-wrapped with new KEK
During KEK rotation, only the wrapped_dek changes. The encrypted_value
remains the same because we reuse the same DEK.
"""
encrypted_value: bytes
wrapped_dek: bytes
class KEKManager:
"""
Manages Key Encryption Keys (KEK) for MLflow encrypted data (API Keys, etc.).
KEK is derived from a passphrase using PBKDF2-HMAC-SHA256. The same passphrase
always produces the same KEK (deterministic derivation).
The passphrase can be provided either via the constructor (which is used for rotating KEKs
and updating DEKs in the database) or via the MLFLOW_CRYPTO_KEK_PASSPHRASE environment
variable (which is used during normal server operation).
If no passphrase is provided and MLFLOW_CRYPTO_KEK_PASSPHRASE is not set, a default
passphrase is used. This enables the gateway to work out-of-the-box for development,
but is NOT recommended for production use.
Args:
passphrase: Optional passphrase. If None, reads from MLFLOW_CRYPTO_KEK_PASSPHRASE env var.
If env var is also not set, uses DEFAULT_KEK_PASSPHRASE.
kek_version: Optional version identifier for this KEK. If None, reads from
MLFLOW_CRYPTO_KEK_VERSION env var (default 1). Used to track which KEK version
was used to wrap each DEK, enabling KEK rotation.
"""
def __init__(self, passphrase: str | None = None, kek_version: int | None = None):
if passphrase is None:
passphrase = os.environ.get(CRYPTO_KEK_PASSPHRASE_ENV_VAR)
if kek_version is None:
kek_version = int(os.environ.get(CRYPTO_KEK_VERSION_ENV_VAR, "1"))
# Use default passphrase if none provided
self._using_default_passphrase = not passphrase
if not passphrase:
passphrase = DEFAULT_KEK_PASSPHRASE
_logger.warning(
"MLFLOW_CRYPTO_KEK_PASSPHRASE not set. Using default passphrase for "
"secrets encryption. This is acceptable for local development (localhost) "
"but is a SECURITY RISK for remote or shared tracking servers. Anyone with "
"database access can decrypt secrets when using the default passphrase. "
"Set MLFLOW_CRYPTO_KEK_PASSPHRASE to a unique, high-entropy value for any "
"server accessible by multiple users or over a network."
)
self._kek_version = kek_version
self._kek = self._derive_kek(passphrase, kek_version)
_logger.debug("KEK derived from passphrase (version %d)", kek_version)
def _derive_kek(self, passphrase: str, kek_version: int) -> bytes:
"""
Derive a 256-bit KEK from passphrase using PBKDF2-HMAC-SHA256.
Args:
passphrase: Admin-provided passphrase
kek_version: KEK version number to fold into salt
Returns:
32-byte (256-bit) KEK
NB: We fold kek_version into the salt to ensure that different KEK versions
produce different KEKs even if the same passphrase is accidentally reused.
This provides defense-in-depth against passphrase reuse during KEK rotation.
The version is encoded as 4 big-endian bytes appended to the base salt.
"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# NB: Folding kek_version into salt ensures unique KEK per version even with same passphrase
versioned_salt = MLFLOW_KEK_SALT + kek_version.to_bytes(4, "big")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=AES_256_KEY_LENGTH,
salt=versioned_salt,
iterations=PBKDF2_ITERATIONS,
)
return kdf.derive(passphrase.encode("utf-8"))
def get_kek(self) -> bytes:
"""
Get the derived KEK.
Returns:
32-byte (256-bit) KEK
"""
return self._kek
@property
def kek_version(self) -> int:
"""
Get the KEK version.
Returns:
KEK version number
"""
return self._kek_version
@property
def using_default_passphrase(self) -> bool:
"""
Check if using the default passphrase.
Returns:
True if using the default passphrase (MLFLOW_CRYPTO_KEK_PASSPHRASE not set)
"""
return self._using_default_passphrase
def _generate_dek() -> bytes:
"""
Generate a random 256-bit Data Encryption Key (DEK).
Each secret gets its own unique DEK, which is then wrapped (encrypted)
with the KEK for storage.
Uses AESGCM's built-in key generation which ensures cryptographically
secure random bytes from the OS.
Returns:
32-byte (256-bit) random DEK
NB: See cryptography.io documentation for key generation best practices:
https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM.generate_key
https://cryptography.io/en/latest/random-numbers/
"""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
return AESGCM.generate_key(bit_length=256)
def _encrypt_with_aes_gcm(
plaintext: bytes,
key: bytes,
*,
aad: bytes | None = None,
_nonce_for_testing: bytes | None = None,
) -> AESGCMResult:
"""
Encrypt plaintext using AES-256-GCM. INTERNAL FUNCTION.
AES-GCM provides authenticated encryption with associated data (AEAD),
which means tampering is detected automatically during decryption.
CRITICAL: Never reuse a nonce with the same key. Nonce reuse completely compromises
AES-GCM security, allowing attackers to recover plaintext and forge messages.
Args:
plaintext: Data to encrypt
key: 32-byte AES-256 key
aad: Optional Additional Authenticated Data. If provided, this data is
authenticated but not encrypted. Useful for binding encryption to
metadata (e.g., secret_id + secret_name) to prevent substitution attacks.
_nonce_for_testing: FOR TESTING ONLY. 12-byte nonce for deterministic encryption
in tests. In production, leave as None to generate a cryptographically secure
random nonce. DO NOT use this parameter in production code.
Returns:
AESGCMResult with nonce and ciphertext
Raises:
ValueError: If key length is not 32 bytes or nonce length is not 12 bytes
NB: See cryptography.io AESGCM documentation for security warnings:
https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM
"Reuse of a nonce with a given key compromises the security of any message with that
nonce and key pair."
"""
if len(key) != AES_256_KEY_LENGTH:
raise ValueError(f"Key must be {AES_256_KEY_LENGTH} bytes (256 bits), got {len(key)}")
nonce = os.urandom(GCM_NONCE_LENGTH) if _nonce_for_testing is None else _nonce_for_testing
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
return AESGCMResult(nonce=nonce, ciphertext=ciphertext)
def decrypt_with_aes_gcm(ciphertext: bytes, key: bytes, aad: bytes | None = None) -> bytes:
"""
Decrypt ciphertext using AES-256-GCM.
Automatically verifies authentication tag to detect tampering.
Args:
ciphertext: Encrypted data with nonce prepended
key: 32-byte AES-256 key
aad: Optional Additional Authenticated Data. Must match the AAD used during
encryption. If AAD was used during encryption but not provided here,
decryption will fail.
Returns:
Decrypted plaintext
Raises:
ValueError: If key length is not 32 bytes or ciphertext is too short
MlflowException: If authentication fails (tampering detected or AAD mismatch)
"""
if len(key) != AES_256_KEY_LENGTH:
raise ValueError(f"Key must be {AES_256_KEY_LENGTH} bytes (256 bits), got {len(key)}")
if len(ciphertext) < GCM_NONCE_LENGTH:
raise ValueError(f"Ciphertext too short (must be at least {GCM_NONCE_LENGTH} bytes)")
nonce = ciphertext[:GCM_NONCE_LENGTH]
encrypted_data = ciphertext[GCM_NONCE_LENGTH:]
try:
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, encrypted_data, aad)
except InvalidTag as e:
raise MlflowException(
"AES-GCM decryption failed: authentication tag verification failed. "
"This indicates wrong key, AAD mismatch, or data tampering.",
error_code=INVALID_PARAMETER_VALUE,
) from e
def wrap_dek(dek: bytes, kek: bytes) -> bytes:
"""
Wrap (encrypt) a DEK with the KEK using AES-256-GCM.
This is how we protect the DEK for storage in the database.
Args:
dek: Data Encryption Key to wrap
kek: Key Encryption Key
Returns:
Wrapped (encrypted) DEK with nonce prepended
"""
result = _encrypt_with_aes_gcm(dek, kek)
return result.nonce + result.ciphertext
def unwrap_dek(wrapped_dek: bytes, kek: bytes) -> bytes:
"""
Unwrap (decrypt) a DEK using the KEK.
Args:
wrapped_dek: Encrypted DEK
kek: Key Encryption Key
Returns:
Unwrapped (decrypted) DEK
Raises:
MlflowException: If KEK is wrong or data tampered
"""
try:
return decrypt_with_aes_gcm(wrapped_dek, kek)
except MlflowException as e:
raise MlflowException(
"Failed to unwrap DEK: incorrect KEK or corrupted wrapped DEK.",
error_code=INVALID_PARAMETER_VALUE,
) from e
def _create_aad(secret_id: str, secret_name: str) -> bytes:
"""
Create Additional Authenticated Data (AAD) from secret metadata.
AAD binds the encryption to specific metadata, preventing ciphertext
substitution attacks where an attacker swaps encrypted values between
different secrets.
Args:
secret_id: Unique secret identifier (UUID)
secret_name: Human-readable secret name
Note that AAD is authenticated but not encrypted. See AESGCM documentation:
https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM
"Associated data that should be authenticated with the key, but does not need
to be encrypted."
Returns:
AAD bytes combining secret_id and secret_name
"""
aad_str = f"{secret_id}|{secret_name}"
return aad_str.encode("utf-8")
def _mask_string_value(value: str) -> str:
"""
Generate a masked version of a single string secret for display purposes.
Shows the first 3-4 characters and last 4 characters with "..." in between.
Args:
value: The plaintext secret string to mask
Note that for strings shorter than 8 characters, this function returns "***" to avoid
information leakage. For API keys with common prefixes (sk-, ghp_, etc.), it shows the
prefix. The function always shows the last 4 characters to help with key identification.
Returns:
Masked string suitable for display
"""
if not isinstance(value, str):
return "***"
if len(value) < 8:
return "***"
prefix_len = 3
prefix = value[:prefix_len]
suffix = value[-4:]
return f"{prefix}...{suffix}"
def _mask_secret_value(secret_value: dict[str, str]) -> dict[str, str]:
"""
Generate a masked version of a secret dict for display purposes.
Each value in the dict is masked using _mask_string_value, which shows the
first 3 characters and last 4 characters with "..." in between.
Args:
secret_value: The plaintext secret values to mask as key-value pairs.
For simple API keys: {"api_key": "sk-xxx..."}
For compound credentials: {"aws_access_key_id": "...", "aws_secret_access_key": "..."}
Returns:
Dict with the same keys but masked values suitable for display.
For example: {"api_key": "sk-...xyz1234"}
Note:
If a value is not a string, it will be masked as "***".
If the input dict is empty, returns an empty dict.
"""
return {key: _mask_string_value(value) for key, value in secret_value.items()}
def _encrypt_secret(
secret_value: str | dict[str, Any],
kek_manager: KEKManager,
secret_id: str,
secret_name: str,
) -> EncryptedSecret:
"""
Encrypt a secret value using envelope encryption with AAD.
This function is the main entry point for encrypting secrets before storing
them in the database. It uses a randomly generated DEK for each secret,
encrypts the secret with AES-256-GCM using AAD, and then wraps the DEK with
the KEK derived from the passphrase.
Note that AAD protection is critical for security. The
AAD (Additional Authenticated Data) binds the encryption to the specific
secret metadata, preventing ciphertext substitution attacks where an attacker
swaps encrypted values between different secrets.
Args:
secret_value: Plaintext secret to encrypt (string or dict). Dicts are JSON-serialized.
kek_manager: KEK manager instance
secret_id: Secret ID for AAD (required for security)
secret_name: Secret name for AAD (required for security)
Returns:
EncryptedSecret with encrypted_value and wrapped_dek. The encrypted_value is
comprised of the nonce + ciphertext + tag, and the wrapped_dek is comprised of
nonce + encrypted DEK + tag.
"""
if isinstance(secret_value, dict):
secret_bytes = json.dumps(secret_value, sort_keys=True).encode("utf-8")
else:
secret_bytes = secret_value.encode("utf-8")
dek = _generate_dek()
aad = _create_aad(secret_id, secret_name)
result = _encrypt_with_aes_gcm(secret_bytes, dek, aad=aad)
encrypted_value = result.nonce + result.ciphertext
kek = kek_manager.get_kek()
wrapped_dek = wrap_dek(dek, kek)
return EncryptedSecret(
encrypted_value=encrypted_value,
wrapped_dek=wrapped_dek,
kek_version=kek_manager.kek_version,
)
def _decrypt_secret(
encrypted_value: bytes,
wrapped_dek: bytes,
kek_manager: KEKManager,
secret_id: str,
secret_name: str,
) -> str | dict[str, Any]:
"""
Decrypt a secret value using envelope encryption with AAD verification.
This function is the main entry point for decrypting secrets retrieved
from the database. It unwraps the DEK with the KEK, then decrypts the secret
value with AES-256-GCM using AAD verification. If the AAD does not match, decryption
will fail, indicating potential tampering or substitution attacks.
Args:
encrypted_value: Encrypted secret from database
wrapped_dek: Wrapped DEK from database
kek_manager: KEK manager instance
secret_id: Secret ID for AAD verification (must match encryption)
secret_name: Secret name for AAD verification (must match encryption)
Note that the AAD must exactly match the values used during encryption. If secret_id or
secret_name have changed in the database, decryption will fail with an InvalidTag Exception.
This protects against ciphertext substitution attacks. If the secret was encrypted as a dict,
it will be returned as a dict. If it was encrypted as a string, it will be returned as a string.
Returns:
Plaintext secret value (string or dict)
Raises:
MlflowException: If decryption fails (wrong KEK, AAD mismatch, or tampered data)
"""
try:
kek = kek_manager.get_kek()
dek = unwrap_dek(wrapped_dek, kek)
aad = _create_aad(secret_id, secret_name)
secret_bytes = decrypt_with_aes_gcm(encrypted_value, dek, aad=aad)
plaintext = secret_bytes.decode("utf-8")
try:
return json.loads(plaintext)
except json.JSONDecodeError:
return plaintext
except Exception as e:
# Provide helpful error message if using default passphrase
if kek_manager.using_default_passphrase:
raise MlflowException(
"Failed to decrypt secret. The server is using the default KEK passphrase, "
"but the secret was likely encrypted with a custom passphrase.\n\n"
"This typically happens when:\n"
"1. Secrets were created with MLFLOW_CRYPTO_KEK_PASSPHRASE set\n"
"2. The server was restarted without MLFLOW_CRYPTO_KEK_PASSPHRASE\n\n"
"To fix this, set MLFLOW_CRYPTO_KEK_PASSPHRASE to the same value that was "
"used when the secrets were created:\n"
" export MLFLOW_CRYPTO_KEK_PASSPHRASE='your-original-passphrase'\n\n"
"If you've lost the original passphrase, the encrypted secrets cannot be "
"recovered and must be recreated.",
error_code=INVALID_PARAMETER_VALUE,
) from e
else:
raise MlflowException(
"Failed to decrypt secret. Check KEK passphrase, secret metadata, "
"or database integrity.",
error_code=INVALID_PARAMETER_VALUE,
) from e
def rotate_secret_encryption(
encrypted_value: bytes,
wrapped_dek: bytes,
old_kek_manager: KEKManager,
new_kek_manager: KEKManager,
) -> RotatedSecret:
"""
Rotate a secret's encryption from old KEK to new KEK.
This is used during KEK rotation to re-encrypt secrets without changing their
plaintext values. The DEK is unwrapped with the old KEK and re-wrapped with
the new KEK. The secret value itself is not re-encrypted (same DEK is used).
Args:
encrypted_value: Current encrypted secret value
wrapped_dek: Current wrapped DEK (encrypted with old KEK)
old_kek_manager: KEK manager with old passphrase
new_kek_manager: KEK manager with new passphrase
Note that KEK rotation requires shutting down the MLflow server to ensure atomicity.
The rotation is atomic (single database transaction) and idempotent. If rotation fails,
nothing is changed and you can safely re-run the command. The admin workflow is:
1. Shut down the MLflow server
2. Set MLFLOW_CRYPTO_KEK_PASSPHRASE to the OLD passphrase
3. Run the rotation CLI command with the NEW passphrase
4. Update MLFLOW_CRYPTO_KEK_PASSPHRASE to the NEW passphrase in deployment config
5. Restart the MLflow server
Returns:
RotatedSecret with unchanged encrypted_value and new wrapped_dek. The encrypted_value
remains unchanged (same DEK, so same ciphertext), while the wrapped_dek contains the
DEK re-wrapped with the new KEK.
Raises:
MlflowException: If decryption with old KEK fails
"""
try:
old_kek = old_kek_manager.get_kek()
dek = unwrap_dek(wrapped_dek, old_kek)
new_kek = new_kek_manager.get_kek()
new_wrapped_dek = wrap_dek(dek, new_kek)
return RotatedSecret(encrypted_value=encrypted_value, wrapped_dek=new_wrapped_dek)
except Exception as e:
raise MlflowException(
"Failed to rotate secret encryption. Check old KEK passphrase or database integrity.",
error_code=INVALID_PARAMETER_VALUE,
) from e