diff --git a/Dockerfile b/Dockerfile index be507f6efb4..31523728210 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,6 +78,7 @@ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ # Install third-party dependencies (cached unless pyproject.toml/uv.lock change) RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ @@ -100,6 +101,7 @@ RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui. # Install project and workspace packages (fast - deps already cached) RUN uv sync --frozen --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ diff --git a/backend/Dockerfile b/backend/Dockerfile index dfff6e71a46..e273d691479 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -43,6 +43,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \ uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ @@ -55,6 +56,7 @@ COPY . . RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 3309fdd5341..bc523d7f1ab 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -76,6 +76,7 @@ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ # Install third-party dependencies (cached unless pyproject.toml/uv.lock change) RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ @@ -98,6 +99,7 @@ RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui. # Install project and workspace packages (fast - deps already cached) RUN uv sync --frozen --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index bafd1af46d1..6bf74c294fc 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -80,6 +80,7 @@ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ @@ -107,6 +108,7 @@ RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ uv sync --frozen --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 8045a8b64cb..e0a5ad4081e 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -59,6 +59,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \ uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ @@ -71,6 +72,7 @@ COPY . . RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index 77bdbd26b35..c85ffef2e2a 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -14,9 +14,7 @@ from typing_extensions import assert_never from litellm._logging import verbose_logger from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS -from litellm.proxy.common_utils.encrypt_decrypt_utils import ( - _V2_GCM_PREFIX, # pyright: ignore[reportPrivateUsage] # reuse the encrypted credential's format discriminator -) +from litellm.proxy.common_utils.encrypt_decrypt_utils import is_versioned_gcm from litellm.types.mcp_server.mcp_server_manager import MCPServer if TYPE_CHECKING: @@ -99,7 +97,7 @@ async def _opaque_bearer_is_gateway_credential(token: str) -> bool: user_api_key_cache, ) - if is_envelope(token) or is_refresh_envelope(token) or token.startswith(_V2_GCM_PREFIX): + if is_envelope(token) or is_refresh_envelope(token) or is_versioned_gcm(token): return True try: if ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) is not None: diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index a2aeef3f67f..abb429ea1d3 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -258,7 +258,7 @@ def _drop_stale_minted_on_client_rotation(merged: dict[str, object], new_creds: } -def _is_global_env_var_scope(scope: object) -> bool: +def is_global_env_var_scope(scope: object) -> bool: """``scope="user"`` entries are placeholders the user fills in; everything else (including a missing scope) is an admin-supplied global value.""" return scope != MCPEnvVarScope.user and scope != "user" @@ -273,7 +273,7 @@ def _encrypt_global_env_var_values(env_vars: Iterable[dict[str, str]]) -> None: secrets and are stored verbatim. """ for entry in env_vars: - if not _is_global_env_var_scope(entry.get("scope")): + if not is_global_env_var_scope(entry.get("scope")): continue value = entry.get("value") if value: @@ -294,7 +294,7 @@ def decrypt_global_env_var_values(env_vars: Iterable[MCPEnvVar | dict[str, str]] for entry in env_vars: is_dict = isinstance(entry, dict) scope = entry.get("scope") if is_dict else getattr(entry, "scope", None) - if not _is_global_env_var_scope(scope): + if not is_global_env_var_scope(scope): continue value = entry.get("value") if is_dict else getattr(entry, "value", None) if not value: @@ -374,7 +374,7 @@ def _reencrypt_global_env_var_values( rebuilt: Final = [dict(v) for v in entries] rotated = False for entry in rebuilt: - if not _is_global_env_var_scope(entry.get("scope")): + if not is_global_env_var_scope(entry.get("scope")): continue value = entry.get("value") if not value: @@ -794,20 +794,22 @@ async def _db_find_user_env_var_rows( return await _user_env_var_actions(prisma_client).find_many(where=where) +MCP_CREDENTIAL_SECRET_FIELDS: Final = ( + "auth_value", + "client_id", + "client_secret", + "client_private_key", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", +) + + def decrypt_credentials( credentials: MCPCredentials, ) -> MCPCredentials: """Decrypt all secret fields in an MCPCredentials dict using the global salt key.""" - secret_fields: Final = [ - "auth_value", - "client_id", - "client_secret", - "client_private_key", - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - ] - for field in secret_fields: + for field in MCP_CREDENTIAL_SECRET_FIELDS: value = credentials.get(field) if value is not None and isinstance(value, str): credentials[field] = decrypt_value_helper( @@ -1536,7 +1538,7 @@ async def rotate_mcp_server_credentials_master_key(prisma_client: PrismaClient, def _decode_user_credential(stored: str) -> str | None: """Read back a value persisted in ``LiteLLM_MCPUserCredentials.credential_b64``. - Tries nacl decryption first (current write format). Falls back to a + Tries the at-rest decryptor first (current write format). Falls back to a plain ``urlsafe_b64decode`` for rows persisted by older code that wrote the credential without encryption. Returns ``None`` when neither path yields a valid string. diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py index df883d5a208..67962b589cf 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py @@ -33,14 +33,13 @@ defend against non-UTF-8 field content that cannot survive JSON parsing. from __future__ import annotations -import base64 from datetime import datetime, timedelta from typing import Final, Literal, TypeAlias import jwt from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError -from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value +from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_if_encrypted_with, encrypt_value_helper ENVELOPE_PREFIX: Final = "llm_env_" """Marker prefix on every serialized ACCESS envelope so the edge can cheaply tell an envelope @@ -540,22 +539,15 @@ def _decode_claims( def _encrypt_grant_blob(plaintext: str, encryption_key: SecretStr) -> str: - ciphertext: Final = bytes(encrypt_value(value=plaintext, signing_key=encryption_key.get_secret_value())) - return base64.urlsafe_b64encode(ciphertext).decode("ascii") + return encrypt_value_helper(value=plaintext, new_encryption_key=encryption_key.get_secret_value()) def _decrypt_grant( blob: str, encryption_key: SecretStr, ) -> UpstreamTokenGrant | DecryptFailed | MalformedPayload: - from nacl.exceptions import CryptoError - - try: - plaintext: Final = decrypt_value( - value=base64.urlsafe_b64decode(blob), - signing_key=encryption_key.get_secret_value(), - ) - except (CryptoError, ValueError): + plaintext: Final = decrypt_if_encrypted_with(value=blob, signing_key=encryption_key.get_secret_value()) + if plaintext is None: return DecryptFailed() try: return UpstreamTokenGrant.model_validate_json(plaintext) @@ -567,14 +559,8 @@ def _decrypt_refresh( blob: str, encryption_key: SecretStr, ) -> RefreshCredential | DecryptFailed | MalformedPayload: - from nacl.exceptions import CryptoError - - try: - plaintext: Final = decrypt_value( - value=base64.urlsafe_b64decode(blob), - signing_key=encryption_key.get_secret_value(), - ) - except (CryptoError, ValueError): + plaintext: Final = decrypt_if_encrypted_with(value=blob, signing_key=encryption_key.get_secret_value()) + if plaintext is None: return DecryptFailed() try: return RefreshCredential.model_validate_json(plaintext) diff --git a/litellm/proxy/client/cli/commands/encryption.py b/litellm/proxy/client/cli/commands/encryption.py index f9a9356d0d6..9d332bee442 100644 --- a/litellm/proxy/client/cli/commands/encryption.py +++ b/litellm/proxy/client/cli/commands/encryption.py @@ -29,11 +29,11 @@ def encryption(): ) @click.pass_context def migrate(ctx: click.Context, check_only: bool, dry_run: bool): - """Re-encrypt at-rest credentials into the AES-256-GCM (v2:gcm:) format. + """Re-encrypt at-rest credentials into the versioned AES-256-GCM (v3:gcm:) format. - Requires the proxy to be started with - ``general_settings.encryption_algorithm: aes-256-gcm``. Idempotent and - resumable; safe to re-run after an interruption. + Requires the proxy to write ``aes-256-gcm`` (the default; refused while + ``general_settings.encryption_algorithm: xsalsa20-poly1305`` is set). Idempotent + and resumable; safe to re-run after an interruption. Examples: lite encryption migrate --check # attestation scan, no writes diff --git a/litellm/proxy/common_utils/credential_hydration.py b/litellm/proxy/common_utils/credential_hydration.py index 2aabe8cad5c..7f4819b49fb 100644 --- a/litellm/proxy/common_utils/credential_hydration.py +++ b/litellm/proxy/common_utils/credential_hydration.py @@ -12,7 +12,7 @@ from types import MappingProxyType from typing import Final import litellm -from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper +from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper, legacy_unreadable from litellm.proxy.utils import PrismaClient from litellm.repositories.credentials_repository import CredentialsRepository from litellm.router_utils.clientside_credential_handler import clientside_credential_keys @@ -58,20 +58,26 @@ def stored_credential_provider(credential_provider: object) -> str | None: return lowered if lowered in _LITELLM_PROVIDER_IDS else None -def decrypted_or_stored(key: str, value: str) -> str: - """The stored value decrypted, or as stored when it was never encrypted (a config.yaml value).""" +def decrypted_or_stored(key: str, value: str) -> str | None: + """The stored value decrypted, as stored when it was never encrypted (a config.yaml value), or None when it + is legacy ciphertext this install has no PyNaCl to read: a credential, never the ciphertext blob.""" decrypted: Final = decrypt_value_helper(value=value, key=key) - return value if decrypted is None else decrypted + if decrypted is not None: + return decrypted + return None if legacy_unreadable(value) else value + + +def decrypted_values(values: Mapping[str, str]) -> Mapping[str, str]: + """``values`` with every entry decrypted, plaintext entries kept, and unreadable legacy entries dropped.""" + resolved: Final = {key: decrypted_or_stored(key, value) for key, value in values.items()} + return MappingProxyType({key: value for key, value in resolved.items() if value is not None}) def _decrypted(db_credential: CredentialItem) -> CredentialItem: """The stored credential with every value decrypted, leaving already-plaintext values alone.""" - decrypted_values: Final = MappingProxyType( - {key: decrypted_or_stored(key, value) for key, value in db_credential.credential_values.items()} - ) return CredentialItem( credential_name=db_credential.credential_name, - credential_values=decrypted_values, # pyright: ignore[reportArgumentType] # declared dict[str, str], and pydantic copies this mapping into one on validation; LIT002 rules out building that dict here + credential_values=decrypted_values(db_credential.credential_values), # pyright: ignore[reportArgumentType] # declared dict[str, str], and pydantic copies this mapping into one on validation; LIT002 rules out building that dict here credential_info=db_credential.credential_info, ) diff --git a/litellm/proxy/common_utils/encrypt_decrypt_utils.py b/litellm/proxy/common_utils/encrypt_decrypt_utils.py index ae7240b8a7f..cb2be0e672d 100644 --- a/litellm/proxy/common_utils/encrypt_decrypt_utils.py +++ b/litellm/proxy/common_utils/encrypt_decrypt_utils.py @@ -1,27 +1,47 @@ import base64 +import hashlib import os -from collections.abc import Mapping -from typing import Final, Literal, cast +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Final, Literal, cast from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger +from litellm.proxy.common_utils.fips import FipsModeError, is_fips_mode -# Versioned ciphertext marker for AES-256-GCM values. -# Format: "v2:gcm:" + base64url(nonce(12) || ciphertext || tag(16)). +if TYPE_CHECKING: + from nacl.secret import SecretBox + +# Versioned ciphertext markers for AES-256-GCM values, each +# ":gcm:" + base64url(nonce(12) || ciphertext || tag(16)). +# v3 derives the key with HKDF-SHA256, v2 with a raw SHA-256 of the salt key. # Legacy XSalsa20-Poly1305 (nacl) values carry no marker; the colon in the # prefix can never appear in base64url(nacl output), so the prefix check is an -# unambiguous discriminator between the two formats on read. +# unambiguous discriminator between the formats on read. +_V3_GCM_PREFIX: Final = "v3:gcm:" _V2_GCM_PREFIX: Final = "v2:gcm:" +_GCM_PREFIXES: Final = (_V3_GCM_PREFIX, _V2_GCM_PREFIX) +_HKDF_INFO: Final = b"litellm-at-rest-v3" -# general_settings key selecting the at-rest encryption algorithm for new writes. -# Default preserves the legacy algorithm so existing deployments are byte-for-byte -# unchanged until they explicitly opt in. Decrypt is always format-detecting, so -# flipping this flag forward (or back) never strands previously-written data. _ENCRYPTION_ALGORITHM_SETTING: Final = "encryption_algorithm" _ALGO_AES_GCM: Final = "aes-256-gcm" _ALGO_XSALSA20: Final = "xsalsa20-poly1305" +_NACL_MIN_CIPHERTEXT_BYTES: Final = 40 +_LEGACY_ENCRYPTION_HELP: Final = ( + "Install the legacy-encryption extra (pip install 'litellm[legacy-encryption]') on a non-FIPS image and " + "re-encrypt stored credentials with `litellm-proxy encryption migrate` (POST /credentials/migrate-encryption) " + "before running without PyNaCl" +) + + +class LegacyEncryptionUnavailableError(RuntimeError): + pass + + +def is_versioned_gcm(value: str) -> bool: + return value.startswith(_GCM_PREFIXES) + def _get_salt_key(): from litellm.proxy.proxy_server import master_key @@ -35,49 +55,48 @@ def _get_salt_key(): def _get_encryption_algorithm() -> str: - """ - Resolve the configured at-rest encryption algorithm for *new writes*. + """Resolve the at-rest encryption algorithm for new writes from ``general_settings.encryption_algorithm``. - Read from ``general_settings.encryption_algorithm`` at write time. Defaults to - the legacy XSalsa20-Poly1305 algorithm so deployments that have not opted in - keep producing byte-for-byte identical ciphertext. + Defaults to AES-256-GCM. ``xsalsa20-poly1305`` stays available as an explicit opt-in for deployments that + still need byte-for-byte legacy output, except under ``LITELLM_FIPS_MODE`` where it is refused. """ try: from litellm.proxy.proxy_server import general_settings - algo: Final = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING, _ALGO_XSALSA20) - except Exception: - # general_settings may not be importable in some contexts (e.g. SDK-only - # use of these helpers). Fall back to the legacy algorithm. - return _ALGO_XSALSA20 - - if isinstance(algo, str) and algo.lower() == _ALGO_AES_GCM: + algo: Final = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING, _ALGO_AES_GCM) + except Exception: # noqa: BLE001 # proxy_server is not importable in SDK-only use of these helpers return _ALGO_AES_GCM + + if not isinstance(algo, str) or algo.lower() != _ALGO_XSALSA20: + return _ALGO_AES_GCM + if is_fips_mode(): + raise FipsModeError( + f"general_settings.{_ENCRYPTION_ALGORITHM_SETTING}={_ALGO_XSALSA20} is not allowed under " + f"LITELLM_FIPS_MODE: XSalsa20-Poly1305 is not a FIPS approved algorithm. Remove the setting to write " + f"{_ALGO_AES_GCM}" + ) return _ALGO_XSALSA20 -def _derive_key(signing_key: str) -> bytes: - """Derive a 32-byte key from the salt/master key (shared by both algorithms). - - Known limitation: this is a single-pass, unsalted ``SHA-256`` of the key, not - a dedicated KDF (HKDF/PBKDF2). It is the *same* derivation the legacy nacl - path already uses, so the AES path introduces no new weakness and stays - interoperable with existing key sourcing; AES-256-GCM's per-value 12-byte - random nonce gives the unique (key, nonce) pairs GCM requires. Moving both - algorithms to HKDF-SHA256 would be more defensible in an audit but is a - separate, coordinated change (it must re-derive or re-encrypt existing data). - """ - import hashlib - +def _derive_key_sha256(signing_key: str) -> bytes: + """Historical derivation shared by legacy nacl values and ``v2:gcm:`` values: one unsalted SHA-256.""" return hashlib.sha256(signing_key.encode()).digest() +def _derive_key_hkdf(signing_key: str) -> bytes: + """Derivation for ``v3:gcm:`` values: HKDF-SHA256 with a fixed info string.""" + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_HKDF_INFO).derive(signing_key.encode()) + + def _seal_aes_gcm(value: str, signing_key: str, aad: bytes | None) -> bytes: from cryptography.hazmat.primitives.ciphers.aead import AESGCM nonce: Final = os.urandom(12) # AESGCM.encrypt returns ciphertext || tag(16); wire format is nonce || that. - return nonce + AESGCM(_derive_key(signing_key)).encrypt(nonce, value.encode("utf-8"), aad) + return nonce + AESGCM(_derive_key_sha256(signing_key)).encrypt(nonce, value.encode("utf-8"), aad) def _open_aes_gcm(sealed: bytes, signing_key: str, aad: bytes | None) -> str: @@ -86,19 +105,33 @@ def _open_aes_gcm(sealed: bytes, signing_key: str, aad: bytes | None) -> str: # An empty plaintext still serializes to nonce(12) || tag(16) = 28 bytes, so a # short/empty buffer here is a corrupt value: let AESGCM.decrypt raise and be # swallowed by the caller (returns None/original), same as legacy. - return AESGCM(_derive_key(signing_key)).decrypt(sealed[:12], sealed[12:], aad).decode("utf-8") + return AESGCM(_derive_key_sha256(signing_key)).decrypt(sealed[:12], sealed[12:], aad).decode("utf-8") def _encrypt_aes_gcm(value: str, signing_key: str) -> str: - """Encrypt under AES-256-GCM and return the versioned ``v2:gcm:`` string.""" - sealed: Final = _seal_aes_gcm(value=value, signing_key=signing_key, aad=None) - return _V2_GCM_PREFIX + base64.urlsafe_b64encode(sealed).decode("utf-8") + """Encrypt under AES-256-GCM and return the versioned ``v3:gcm:`` string.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + nonce: Final = os.urandom(12) + # AESGCM.encrypt returns ciphertext || tag(16); wire format is nonce || that. + blob: Final = AESGCM(_derive_key_hkdf(signing_key)).encrypt(nonce, value.encode("utf-8"), None) + return _V3_GCM_PREFIX + base64.urlsafe_b64encode(nonce + blob).decode("utf-8") def _decrypt_aes_gcm(value: str, signing_key: str) -> str: - """Decrypt a versioned ``v2:gcm:`` string produced by :func:`_encrypt_aes_gcm`.""" - sealed: Final = base64.urlsafe_b64decode(value[len(_V2_GCM_PREFIX) :]) - return _open_aes_gcm(sealed=sealed, signing_key=signing_key, aad=None) + """Decrypt a versioned ``v3:gcm:`` or ``v2:gcm:`` string, deriving the key the way its version was written.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + if value.startswith(_V3_GCM_PREFIX): + key, prefix = _derive_key_hkdf(signing_key), _V3_GCM_PREFIX + else: + key, prefix = _derive_key_sha256(signing_key), _V2_GCM_PREFIX + raw: Final = base64.urlsafe_b64decode(value[len(prefix) :]) + # An empty plaintext still serializes to nonce(12) || tag(16) = 28 bytes, so a + # short/empty buffer here is a corrupt value: let AESGCM.decrypt raise and be + # swallowed by decrypt_value_helper (returns None/original), same as legacy. + nonce, blob = raw[:12], raw[12:] + return AESGCM(key).decrypt(nonce, blob, None).decode("utf-8") def encrypt_bearer_token(value: str, prefix: str) -> str: @@ -129,7 +162,7 @@ def encrypt_value_helper(value: str, new_encryption_key: str | None = None): try: if isinstance(value, str): if _get_encryption_algorithm() == _ALGO_AES_GCM: - # AES path: the v2:gcm: output is already a base64url string, so it + # AES path: the v3:gcm: output is already a base64url string, so it # is returned directly with no extra base64 wrapper. return _encrypt_aes_gcm(value=value, signing_key=cast(str, signing_key)) @@ -160,19 +193,26 @@ def _legacy_ciphertext_bytes(value: str) -> bytes: def _decrypt_with_signing_key(value: str, signing_key: str) -> str: # Versioned AES-256-GCM values are detected before any base64 decode. # The prefix is the algorithm tag the legacy nacl format never carried. - if value.startswith(_V2_GCM_PREFIX): + if is_versioned_gcm(value): return _decrypt_aes_gcm(value=value, signing_key=signing_key) return decrypt_value(value=_legacy_ciphertext_bytes(value), signing_key=signing_key) def decrypt_if_encrypted_with(value: str, signing_key: str) -> str | None: - """None unless value is a ciphertext under signing_key.""" + """None unless value is a ciphertext under signing_key. + + A legacy ciphertext met without PyNaCl installed is logged with the re-encrypt path and read as None, + never handed back as if it were the plaintext. + """ try: # base64 decoding skips characters outside its alphabet, so "" and "*" decode to no bytes, # which decrypt_value reads as an empty plaintext under any key. - decodes_to_nothing: Final = not value.startswith(_V2_GCM_PREFIX) and not _legacy_ciphertext_bytes(value) + decodes_to_nothing: Final = not is_versioned_gcm(value) and not _legacy_ciphertext_bytes(value) return None if decodes_to_nothing else _decrypt_with_signing_key(value=value, signing_key=signing_key) + except LegacyEncryptionUnavailableError as error: + verbose_proxy_logger.error("%s", error) + return None except Exception: # noqa: BLE001 # base64, nacl and AES-GCM each raise their own "not a ciphertext" type return None @@ -191,6 +231,9 @@ def decrypt_value_helper( # if it's not str - do not decrypt it, return the value return value + except LegacyEncryptionUnavailableError as error: + verbose_proxy_logger.error("Cannot decrypt value for key: %s. %s", key, error) + return None except Exception as e: error_message = f"Error decrypting value for key: {key}, Did your master_key/salt key change recently? \nError: {e}\nSet permanent salt key - https://docs.litellm.ai/docs/proxy/prod#5-set-litellm-salt-key" if exception_type == "debug": @@ -206,50 +249,58 @@ def decrypt_value_helper( return None -def encrypt_value(value: str, signing_key: str): - import hashlib +def legacy_encryption_available() -> bool: + """True when PyNaCl is importable, so legacy xsalsa20-poly1305 ciphertext can be read.""" + try: + import nacl.secret # noqa: F401 # probe only + except ImportError: + return False + return True - import nacl.secret - import nacl.utils - # get 32 byte master key # - hash_object: Final = hashlib.sha256(signing_key.encode()) - hash_bytes: Final = hash_object.digest() +def needs_legacy_reader(value: object) -> bool: + """True for a stored string that only PyNaCl can tell apart from plaintext: non empty and unprefixed.""" + return isinstance(value, str) and value != "" and not is_versioned_gcm(value) - # initialize secret box # - box: Final = nacl.secret.SecretBox(hash_bytes) - # encode message # - value_bytes: Final = value.encode("utf-8") +def legacy_unreadable(value: object) -> bool: + """True for a stored value that only PyNaCl could read and PyNaCl is missing: such a value must read as unset, + never as a plaintext credential.""" + return needs_legacy_reader(value) and not legacy_encryption_available() - encrypted: Final = box.encrypt(value_bytes) - return encrypted +def require_legacy_reader_for(values: Iterable[object], purpose: str) -> None: + """Refuse a decrypt-then-rewrite pass when PyNaCl is missing and one of the values is unprefixed: it would + read as unreadable and be dropped, double wrapped or miscounted as plaintext. Versioned gcm and non string + values never need PyNaCl, so a fully migrated store passes.""" + if legacy_encryption_available() or not any(needs_legacy_reader(value) for value in values): + return + raise LegacyEncryptionUnavailableError( + f"Cannot {purpose}: PyNaCl is needed to read legacy {_ALGO_XSALSA20} values. {_LEGACY_ENCRYPTION_HELP}" + ) + + +def _legacy_secret_box(signing_key: str, purpose: str) -> "SecretBox": + try: + import nacl.secret + except ImportError as error: + raise LegacyEncryptionUnavailableError( + f"Cannot {purpose} with the legacy {_ALGO_XSALSA20} algorithm: PyNaCl is not installed. " + f"{_LEGACY_ENCRYPTION_HELP}" + ) from error + return nacl.secret.SecretBox(_derive_key_sha256(signing_key)) + + +def encrypt_value(value: str, signing_key: str) -> bytes: + return bytes(_legacy_secret_box(signing_key, "encrypt").encrypt(value.encode("utf-8"))) def decrypt_value(value: bytes, signing_key: str) -> str: - import hashlib - - import nacl.secret - import nacl.utils - - # get 32 byte master key # - hash_object: Final = hashlib.sha256(signing_key.encode()) - hash_bytes: Final = hash_object.digest() - - # initialize secret box # - box: Final = nacl.secret.SecretBox(hash_bytes) - - # Convert the bytes object to a string - try: - if len(value) == 0: - return "" - - plaintext = box.decrypt(value) - plaintext = plaintext.decode("utf-8") - return plaintext - except Exception as e: - raise e + if len(value) == 0: + return "" + if len(value) < _NACL_MIN_CIPHERTEXT_BYTES: + raise ValueError(f"Value of {len(value)} bytes is too short to be a {_ALGO_XSALSA20} ciphertext") + return _legacy_secret_box(signing_key, "decrypt a stored value").decrypt(value).decode("utf-8") class SecretMapDecodeError(RuntimeError): diff --git a/litellm/proxy/management_endpoints/credential_migration.py b/litellm/proxy/management_endpoints/credential_migration.py index f9c09128f66..05d0de23a13 100644 --- a/litellm/proxy/management_endpoints/credential_migration.py +++ b/litellm/proxy/management_endpoints/credential_migration.py @@ -2,9 +2,9 @@ At-rest credential re-encryption migration. Switches every encrypted-at-rest value from the legacy XSalsa20-Poly1305 (nacl) -format to the versioned AES-256-GCM (``v2:gcm:``) format produced by -``encrypt_decrypt_utils`` when ``general_settings.encryption_algorithm`` is set to -``aes-256-gcm``. +format to the versioned AES-256-GCM (``v3:gcm:``) format produced by +``encrypt_decrypt_utils`` by default (``general_settings.encryption_algorithm`` +not set to the legacy ``xsalsa20-poly1305``). Design properties (see case 2026-06-24 fix plan): @@ -12,7 +12,7 @@ Design properties (see case 2026-06-24 fix plan): it re-encrypts existing ciphertext under the same derived key but in the new AES format. This is achieved by decrypting with the format-detecting reader and re-encrypting through ``encrypt_value_helper`` with the AES gate enabled. -* **Idempotent.** A value already carrying the ``v2:gcm:`` prefix is recognised +* **Idempotent.** A value already carrying a ``v3:gcm:`` or ``v2:gcm:`` prefix is recognised and left untouched, so re-running the migration is a no-op on migrated rows. * **Resumable.** Walkers commit per row (or per small table), so an interrupted run leaves a clean mixed state that a re-run completes. @@ -31,6 +31,7 @@ config rows, and the SSO config table. """ import json +from collections.abc import Iterator from dataclasses import dataclass, field from typing import TYPE_CHECKING, Final, Literal, cast @@ -43,12 +44,14 @@ if TYPE_CHECKING: from litellm.proxy.common_utils.encrypt_decrypt_utils import ( _ALGO_AES_GCM, _ENCRYPTION_ALGORITHM_SETTING, - _V2_GCM_PREFIX, SecretMapDecodeError, + _get_encryption_algorithm, _get_salt_key, decode_secret_map, decrypt_value_helper, encrypt_value_helper, + is_versioned_gcm, + require_legacy_reader_for, ) ValueClass = Literal["migrated", "legacy", "plaintext", "undecryptable", "not-a-string"] @@ -60,7 +63,7 @@ class LocationReport: location: str scanned: int = 0 - migrated: int = 0 # values rewritten to v2 this run + migrated: int = 0 # values rewritten to versioned AES this run already_v2: int = 0 # values already migrated (skipped) plaintext: int = 0 # legacy-plaintext values (no ciphertext to migrate) undecryptable: int = 0 # could not decrypt — preserved, not overwritten @@ -125,15 +128,15 @@ class MigrationReport: def is_migrated(value: object) -> bool: - """True if ``value`` is already an AES-256-GCM (``v2:gcm:``) ciphertext.""" - return isinstance(value, str) and value.startswith(_V2_GCM_PREFIX) + """True if ``value`` is already a versioned AES-256-GCM (``v3:gcm:`` or ``v2:gcm:``) ciphertext.""" + return isinstance(value, str) and is_versioned_gcm(value) def classify_value(value: object, key: str = "scan") -> ValueClass: """Classify a stored value for the residual scanner. * ``not-a-string`` — not a string (numbers/bools/None left as-is on disk). - * ``migrated`` — carries the ``v2:gcm:`` prefix. + * ``migrated`` — carries a versioned ``gcm`` prefix. * ``legacy`` — decrypts under the legacy nacl reader (still needs migrating). * ``plaintext`` — a non-empty string that does not decrypt and is not v2; treated as legacy plaintext (nothing to migrate). @@ -145,8 +148,9 @@ def classify_value(value: object, key: str = "scan") -> ValueClass: return "not-a-string" if value == "": return "plaintext" - if value.startswith(_V2_GCM_PREFIX): + if is_versioned_gcm(value): return "migrated" + require_legacy_reader_for((value,), "scan stored encryption") decrypted: Final = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False) if decrypted is None: # Did not decrypt under nacl and has no v2 marker: legacy plaintext. @@ -157,15 +161,16 @@ def classify_value(value: object, key: str = "scan") -> ValueClass: def reencrypt_value(value: object, key: str = "migrate") -> object: """Re-encrypt a single stored string into the configured (AES) format. - Returns the value unchanged if it is not a string, is already ``v2:``, or + Returns the value unchanged if it is not a string, is already versioned AES, or cannot be decrypted (skip-on-undecryptable). Otherwise decrypts under the format-detecting reader and re-encrypts through ``encrypt_value_helper`` (which writes AES when the gate is on). """ if not isinstance(value, str) or value == "": return value - if value.startswith(_V2_GCM_PREFIX): + if is_versioned_gcm(value): return value # idempotent: already migrated + require_legacy_reader_for((value,), "migrate stored encryption") decrypted: Final = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False) if decrypted is None: # Either legacy plaintext (no ciphertext to migrate) or corrupt. Either @@ -195,13 +200,11 @@ def _assert_aes_gate_enabled() -> None: Running the migration with the gate off would decrypt then re-encrypt right back into the legacy format — a no-op that silently fails the migration. """ - from litellm.proxy.proxy_server import general_settings - - algo: Final = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING) - if not (isinstance(algo, str) and algo.lower() == _ALGO_AES_GCM): + algo: Final = _get_encryption_algorithm() + if algo != _ALGO_AES_GCM: raise RuntimeError( - "Encryption migration requires general_settings.encryption_algorithm: " - f"'{_ALGO_AES_GCM}'. Current value: {algo!r}. Set it before migrating " + f"Encryption migration requires general_settings.{_ENCRYPTION_ALGORITHM_SETTING}: " + f"'{_ALGO_AES_GCM}' (the default). Current value: {algo!r}. Remove the legacy opt-in before migrating " "so re-encrypted values are written in the AES-256-GCM format." ) @@ -430,7 +433,7 @@ def _classify_callback_value(value: object) -> ValueClass: Encrypted callback vars carry the ``litellm_enc::`` marker in front of the ciphertext; strip it, then classify the inner value the same way the - covered-table scanner does (``v2:gcm:`` prefix -> migrated, nacl-decryptable + covered-table scanner does (versioned ``gcm`` prefix -> migrated, nacl-decryptable -> legacy, otherwise plaintext). Detecting legacy by decrypt rather than by a re-encrypt delta is what makes the ``check_encryption`` attestation correct even when run with the AES write gate off. @@ -463,8 +466,11 @@ _COVERED_TABLE_SPECS: Final = [ ("mcp_server", "litellm_mcpservertable", ("credentials", "env_vars", "static_headers", "env"), ()), ("mcp_user_credentials", "litellm_mcpusercredentials", (), ("credential_b64",)), ("mcp_user_env_vars", "litellm_mcpuserenvvars", (), ("values_b64",)), + ("mcp_oauth_client", "litellm_mcpserveroauthclient", ("credentials",), ()), ("search_tools", "litellm_searchtoolstable", ("litellm_params",), ()), + ("sso_identity_assertion", "litellm_ssoidentityassertion", (), ("assertion_b64",)), ] +_MCP_CREDENTIAL_TABLES: Final = frozenset({"litellm_mcpservertable", "litellm_mcpserveroauthclient"}) def _iter_encrypted_strings(obj: object): @@ -485,6 +491,31 @@ def _iter_encrypted_strings(obj: object): stack.extend(cur) +def _mcp_encrypted_leaves(col: str, raw: object) -> Iterator[str]: + """Only the strings an MCP column encrypts at rest: a credentials blob keeps auth_type, scopes and urls in + plaintext and env_vars keeps every name and every per user placeholder, so without PyNaCl those must not be + mistaken for legacy ciphertext and refuse the scan""" + from litellm.proxy._experimental.mcp_server.db import MCP_CREDENTIAL_SECRET_FIELDS, is_global_env_var_scope + + if col == "credentials": + if not isinstance(raw, dict): + return iter(()) + return (v for k, v in raw.items() if k in MCP_CREDENTIAL_SECRET_FIELDS and isinstance(v, str)) + if not isinstance(raw, list): + return iter(()) + return ( + e["value"] + for e in raw + if isinstance(e, dict) and is_global_env_var_scope(e.get("scope")) and isinstance(e.get("value"), str) + ) + + +def _encrypted_leaves(db_attr: str, col: str, raw: object) -> Iterator[str]: + if db_attr in _MCP_CREDENTIAL_TABLES and col in ("credentials", "env_vars"): + return _mcp_encrypted_leaves(col, raw) + return _iter_encrypted_strings(raw) + + def _classify_into_report(report: LocationReport, value: str) -> None: """Classify one stored string and bump the matching read-only counter. @@ -495,14 +526,27 @@ def _classify_into_report(report: LocationReport, value: str) -> None: report.count(classify_value(value, key="scan")) +def _secret_map_ciphertext(value: object) -> object: + """The stored ciphertext behind an MCP secret map: the JSON quoted string is unwrapped, a plain map or + anything that is not JSON is returned as is for ``decode_secret_map`` to judge.""" + if not isinstance(value, str) or not value.lstrip().startswith('"'): + return value + try: + return json.loads(value) + except ValueError: + return value + + def _classify_secret_map(value: object, key: str) -> ValueClass | None: + ciphertext: Final = _secret_map_ciphertext(value) + if isinstance(ciphertext, str) and not ciphertext.lstrip().startswith("{"): + require_legacy_reader_for((ciphertext,), "scan stored encryption") try: decoded: Final = decode_secret_map(value, key=key) except SecretMapDecodeError: return "undecryptable" if not decoded: return None - ciphertext: Final = json.loads(value) if isinstance(value, str) and value.lstrip().startswith('"') else value return "migrated" if is_migrated(ciphertext) else "legacy" @@ -535,7 +579,7 @@ async def _scan_one_table( raw = json.loads(raw) except (ValueError, TypeError): pass - for s in _iter_encrypted_strings(raw): + for s in _encrypted_leaves(db_attr, col, raw): _classify_into_report(report, s) for col in scalar_columns: v = getattr(row, col, None) @@ -565,7 +609,7 @@ async def _scan_config_env_vars(prisma_client: object) -> LocationReport: return report -async def _scan_covered_tables(prisma_client: object) -> list[LocationReport]: +async def scan_covered_tables(prisma_client: object) -> list[LocationReport]: """Read-only classification of every rotation-covered table. No writes.""" reports: Final[list[LocationReport]] = [] for location, db_attr, json_cols, scalar_cols in _COVERED_TABLE_SPECS: @@ -598,7 +642,7 @@ async def _migrate_covered_tables(prisma_client: object, user_api_key_dict: obje _rotate_master_key, ) - pre: Final = {r.location: r for r in await _scan_covered_tables(prisma_client)} + pre: Final = {r.location: r for r in await scan_covered_tables(prisma_client)} current_key: Final = _get_salt_key() if current_key is None: @@ -612,7 +656,7 @@ async def _migrate_covered_tables(prisma_client: object, user_api_key_dict: obje new_master_key=current_key, # same key, algorithm-only switch ) - post: Final = await _scan_covered_tables(prisma_client) + post: Final = await scan_covered_tables(prisma_client) for post_report in post: pre_report = pre.get(post_report.location) pre_legacy = pre_report.legacy if pre_report else 0 @@ -629,8 +673,8 @@ async def migrate_encryption( ) -> MigrationReport: """Run the full at-rest re-encryption migration. - Requires ``general_settings.encryption_algorithm == 'aes-256-gcm'`` so writes - are produced in the AES format. Idempotent and resumable: re-running skips + Requires the proxy to write ``aes-256-gcm`` (the default) so re-encrypted + values are produced in the AES format. Idempotent and resumable: re-running skips already-migrated values and finishes any partial run. A ``dry_run`` performs no writes: the covered tables are scanned read-only @@ -645,7 +689,7 @@ async def migrate_encryption( # delegate to the rotation path (with bracketing scans for counts); on a dry # run only classify them read-only. if dry_run: - for covered in await _scan_covered_tables(prisma_client): + for covered in await scan_covered_tables(prisma_client): report.add(covered) else: for covered in await _migrate_covered_tables(prisma_client, user_api_key_dict): @@ -673,7 +717,7 @@ async def check_encryption(prisma_client: object) -> MigrationReport: report: Final = MigrationReport() # Rotation-covered tables (read-only classification). - for covered in await _scan_covered_tables(prisma_client): + for covered in await scan_covered_tables(prisma_client): report.add(covered) # Net-new walker locations, in dry-run (read-only) mode. diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 11810f4bd52..f9b1d6ad511 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -81,6 +81,10 @@ from litellm.proxy.common_utils.config_sync_pubsub import ( coordination_redis_cache, publish_config_change, ) +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + LegacyEncryptionUnavailableError, + legacy_encryption_available, +) from litellm.proxy.common_utils.rbac_utils import check_org_admin_can_generate_keys from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.common_utils.user_api_key_cache import AUTH_OBJECTS_TARGET, UserApiKeyCache @@ -5216,6 +5220,16 @@ async def delete_key_aliases( ) +async def _require_legacy_reader_for_stored_values(prisma_client: PrismaClient) -> None: + """Without PyNaCl, scan the rotation covered tables read only so an unprefixed stored value refuses the + rotation before any row is rewritten. A fully migrated store passes; with PyNaCl installed nothing runs""" + if legacy_encryption_available(): + return + from litellm.proxy.management_endpoints.credential_migration import scan_covered_tables + + await scan_covered_tables(prisma_client) + + async def _rotate_master_key( prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth, @@ -5239,6 +5253,14 @@ async def _rotate_master_key( from litellm.proxy.proxy_server import proxy_config + try: + await _require_legacy_reader_for_stored_values(prisma_client) + except LegacyEncryptionUnavailableError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": str(error)}, + ) from error + try: models: list | None = cast( # cast-ok: find_many returns a real list, which TableActions widens to Sequence "list[object]", await _prisma_table(ModelRepository(prisma_client)).find_many() @@ -5410,9 +5432,9 @@ async def migrate_encryption_endpoint( ), ): """ - Re-encrypt all at-rest credentials into the AES-256-GCM (``v2:gcm:``) format. + Re-encrypt all at-rest credentials into the versioned AES-256-GCM (``v3:gcm:``) format. - Admin only. Requires ``general_settings.encryption_algorithm: aes-256-gcm``. + Admin only. Requires the proxy to write ``aes-256-gcm`` (the default). Idempotent and resumable — re-running skips already-migrated values. Pass ``dry_run=true`` for a non-mutating scan (equivalent to ``--check``). """ diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6d1a04c2b5e..3fc90e5e9ba 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -414,13 +414,14 @@ from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_pr from litellm.proxy.common_utils.codex_model_catalog import codex_model_list_body from litellm.proxy.common_utils.config_includes import resolve_include_file_path, resolve_includes from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber -from litellm.proxy.common_utils.credential_hydration import decrypted_or_stored +from litellm.proxy.common_utils.credential_hydration import decrypted_values from litellm.proxy.common_utils.debug_utils import init_verbose_loggers from litellm.proxy.common_utils.debug_utils import router as debugging_endpoints_router from litellm.proxy.common_utils.discoverable_model_filter import discoverable_rows, undiscoverable_model_names from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, + require_legacy_reader_for, ) from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id from litellm.proxy.common_utils.fips import ( @@ -7695,6 +7696,9 @@ class ProxyConfig: _decrypt_and_set_db_env_variables): this is a write path, and loading values into os.environ is the read path's responsibility. """ + require_legacy_reader_for( + environment_variables.values(), "re-encrypt environment variables for the config save" + ) decrypted_env_vars: Final = self._decrypt_db_variables(environment_variables) return self._encrypt_env_variables( environment_variables=decrypted_env_vars, @@ -9145,16 +9149,10 @@ class ProxyConfig: await initialize_pass_through_endpoints_in_db() def decrypt_credentials(self, credential: dict | BaseModel) -> CredentialItem: - if isinstance(credential, dict): - credential_object = CredentialItem(**credential) - elif isinstance(credential, BaseModel): - credential_object = CredentialItem(**credential.model_dump()) - - decrypted_credential_values: Final = {} - for k, v in credential_object.credential_values.items(): - decrypted_credential_values[k] = decrypted_or_stored(k, v) - - credential_object.credential_values = decrypted_credential_values + credential_object: Final = CredentialItem( + **(credential if isinstance(credential, dict) else credential.model_dump()) + ) + credential_object.credential_values = dict(decrypted_values(credential_object.credential_values)) return credential_object async def delete_credentials(self, db_credentials: list[CredentialItem]): diff --git a/migrations/Dockerfile b/migrations/Dockerfile index 255b94b0ea8..b2038b3b5a8 100644 --- a/migrations/Dockerfile +++ b/migrations/Dockerfile @@ -55,6 +55,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \ uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra extra_proxy \ --python python3.13 @@ -64,6 +65,7 @@ COPY . . RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-default-groups --no-editable \ --extra proxy \ + --extra legacy-encryption \ --extra extra_proxy \ --python python3.13 diff --git a/pyproject.toml b/pyproject.toml index f04e66a04eb..4e127408c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,6 @@ proxy = [ "PyJWT>=2.13.0,<3.0", "python-multipart>=0.0.27,<1.0", "cryptography>=49.0.0,<51.0", - "pynacl>=1.6.2,<2.0", "websockets>=15.0.1,<16.0", "boto3>=1.43.1,<2.0", "azure-identity>=1.25.2,<2.0", @@ -122,6 +121,8 @@ utils = [ "numpydoc>=1.8.0,<2.0", ] caching = ["diskcache>=5.6.3,<6.0"] +# Reads and writes the legacy XSalsa20-Poly1305 at-rest format; the FIPS image omits it +legacy-encryption = ["pynacl>=1.6.2,<2.0"] mcp = ["mcp>=2.2.0,<3", "httpx2>=2.5.0,<3", "pydantic>=2.12.0,<3"] # Driver for the MongoDB Atlas vector store; Atlas Vector Search has no HTTP query API. # The floor is 4.9 because that is the release AsyncMongoClient landed in. @@ -253,6 +254,7 @@ proxy-dev = [ "opentelemetry-instrumentation-fastapi==0.54b1", "azure-identity==1.25.2", "a2a-sdk==1.1.0", + "pynacl>=1.6.2,<2.0", ] ci = [ "psutil==7.2.2", diff --git a/tests/integration/management/test_at_rest_encryption_format.py b/tests/integration/management/test_at_rest_encryption_format.py new file mode 100644 index 00000000000..6ddc07307de --- /dev/null +++ b/tests/integration/management/test_at_rest_encryption_format.py @@ -0,0 +1,263 @@ +"""At-rest encryption writes the versioned AES-256-GCM format and keeps reading every older format. + +The proxy, Postgres and the router run for real. Older rows are built by hand from the wire formats litellm +owns (nacl SecretBox and AES-GCM under the raw SHA-256 derivation) and inserted straight into the model table, +the way a deployment upgraded in place would find them. Every read is proven at the scripted upstream: the +Authorization header it receives is the plaintext key the proxy decrypted. +""" + +import base64 +import hashlib +import json +import os +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Final + +import httpx +import nacl.secret +import psycopg +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from tests.integration._support.client import JSON_OBJECT, Gateway, Scenario, eventually, object_value, string_value +from tests.integration._support.database import read_rows +from tests.integration._support.process import owned_proxy_process + +V3_PREFIX: Final = "v3:gcm:" +V2_PREFIX: Final = "v2:gcm:" + + +def _salt_key() -> str: + return os.environ.get("LITELLM_SALT_KEY", "sk-integration-salt") + + +def _sha256_key() -> bytes: + return hashlib.sha256(_salt_key().encode()).digest() + + +def _legacy_nacl_ciphertext(plaintext: str) -> str: + sealed: Final = nacl.secret.SecretBox(_sha256_key()).encrypt(plaintext.encode()) + return base64.urlsafe_b64encode(bytes(sealed)).decode() + + +def _legacy_v2_ciphertext(plaintext: str) -> str: + nonce: Final = os.urandom(12) + blob: Final = AESGCM(_sha256_key()).encrypt(nonce, plaintext.encode(), None) + return V2_PREFIX + base64.urlsafe_b64encode(nonce + blob).decode() + + +def _stored_api_key(model_id: str) -> str: + rows: Final = read_rows( + "SELECT litellm_params->>'api_key' AS api_key FROM \"LiteLLM_ProxyModelTable\" WHERE model_id = %s", + (model_id,), + ) + assert len(rows) == 1, rows + return string_value(rows[0]["api_key"]) + + +def _stored_credential_api_key(name: str) -> str: + rows: Final = read_rows( + "SELECT credential_values->>'api_key' AS api_key FROM \"LiteLLM_CredentialsTable\" WHERE credential_name = %s", + (name,), + ) + assert len(rows) == 1, rows + return string_value(rows[0]["api_key"]) + + +def _insert_model_row(gateway: Gateway, scenario: Scenario, ciphertext: str) -> tuple[str, str]: + model_id: Final = f"enc-{uuid.uuid4().hex}" + model_name: Final = f"integration-{uuid.uuid4().hex}" + params: Final = { + "model": "openai/gpt-4o-mini", + "api_key": ciphertext, + "api_base": f"{gateway.upstream_url}/v1", + } + with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection: + connection.execute( + 'INSERT INTO "LiteLLM_ProxyModelTable" ' + "(model_id, model_name, litellm_params, model_info, created_by, updated_by) " + "VALUES (%s, %s, %s::jsonb, %s::jsonb, %s, %s)", + (model_id, model_name, json.dumps(params), json.dumps({"id": model_id}), "integration", "integration"), + ) + scenario.cleanups.callback(_delete_model_row_if_present, gateway, model_id) + return model_id, model_name + + +def _delete_model_row_if_present(gateway: Gateway, model_id: str) -> None: + response: Final = gateway.request("POST", "/model/delete", {"id": model_id}) + assert response.status_code in (200, 400, 404), response.text + with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection: + connection.execute('DELETE FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (model_id,)) + + +def _new_model(gateway: Gateway, scenario: Scenario, api_key: str) -> tuple[str, str]: + model_name: Final = f"integration-{uuid.uuid4().hex}" + created: Final = gateway.post( + "/model/new", + { + "model_name": model_name, + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": api_key, + "api_base": f"{gateway.upstream_url}/v1", + }, + "model_info": {}, + }, + ) + model_id: Final = string_value(object_value(created["model_info"])["id"]) + scenario.cleanups.callback(scenario.delete_model, model_id) + return model_id, model_name + + +def _drain_upstream(gateway: Gateway) -> tuple[tuple[str, str], ...]: + with httpx.Client(base_url=gateway.upstream_url, timeout=15, trust_env=False) as upstream: + observed: Final = upstream.get("/__observations") + observed.raise_for_status() + requests: Final = JSON_OBJECT.validate_json(observed.content)["requests"] + assert isinstance(requests, list), requests + entries: Final = tuple(object_value(value) for value in requests) + return tuple((json.dumps(entry["body"]), string_value(entry["authorization"])) for entry in entries) + + +def _authorizations_for(observed: tuple[tuple[str, str], ...], marker: str) -> tuple[str, ...]: + return tuple(authorization for body, authorization in observed if marker in body) + + +def _chat_reaches_upstream_with(gateway: Gateway, model_name: str, plaintext_key: str) -> None: + marker: Final = f"marker-{uuid.uuid4().hex}" + response: Final = eventually( + lambda: gateway.request( + "POST", "/v1/chat/completions", {"model": model_name, "messages": [{"role": "user", "content": marker}]} + ), + lambda observed: observed.status_code == 200, + seconds=70, + ) + assert response.status_code == 200, response.text + assert _authorizations_for(_drain_upstream(gateway), marker) == (f"Bearer {plaintext_key}",) + + +def _model_table_count(gateway: Gateway, counter: str) -> int: + report: Final = object_value(gateway.get("/credentials/migrate-encryption/check")["report"]) + value: Final = object_value(object_value(report["locations"])["model_table"])[counter] + assert isinstance(value, int), report + return value + + +def test_new_model_api_key_is_stored_as_v3_gcm_and_decrypts_for_the_upstream_call(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + plaintext_key: Final = f"sk-upstream-{uuid.uuid4().hex}" + model_id, model_name = _new_model(gateway, scenario, plaintext_key) + stored: Final = _stored_api_key(model_id) + assert stored.startswith(V3_PREFIX), f"expected a {V3_PREFIX} ciphertext, stored {stored!r}" + assert plaintext_key not in stored + _chat_reaches_upstream_with(gateway, model_name, plaintext_key) + + +def test_new_credential_values_are_stored_as_v3_gcm(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + name: Final = f"credential-{uuid.uuid4().hex}" + plaintext_key: Final = f"sk-credential-{uuid.uuid4().hex}" + gateway.post( + "/credentials", + {"credential_name": name, "credential_values": {"api_key": plaintext_key}, "credential_info": {}}, + ) + scenario.cleanups.callback(gateway.request, "DELETE", f"/credentials/{name}") + stored: Final = _stored_credential_api_key(name) + assert stored.startswith(V3_PREFIX), f"expected a {V3_PREFIX} ciphertext, stored {stored!r}" + assert plaintext_key not in stored + + +def test_legacy_nacl_model_row_still_decrypts_for_the_upstream_call(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + plaintext_key: Final = f"sk-nacl-{uuid.uuid4().hex}" + _, model_name = _insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(plaintext_key)) + _chat_reaches_upstream_with(gateway, model_name, plaintext_key) + + +def test_v2_gcm_model_row_still_decrypts_for_the_upstream_call(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + plaintext_key: Final = f"sk-v2-{uuid.uuid4().hex}" + _, model_name = _insert_model_row(gateway, scenario, _legacy_v2_ciphertext(plaintext_key)) + _chat_reaches_upstream_with(gateway, model_name, plaintext_key) + + +def test_encryption_check_counts_v3_as_migrated_and_nacl_as_legacy(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + migrated_before: Final = _model_table_count(gateway, "already_v2") + legacy_before: Final = _model_table_count(gateway, "legacy") + _new_model(gateway, scenario, f"sk-upstream-{uuid.uuid4().hex}") + _insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(f"sk-nacl-{uuid.uuid4().hex}")) + _insert_model_row(gateway, scenario, _legacy_v2_ciphertext(f"sk-v2-{uuid.uuid4().hex}")) + migrated_after: Final = _model_table_count(gateway, "already_v2") + assert migrated_after == migrated_before + 3 + 1, ( + "expected every litellm_params value of the new model (model, api_key, api_base) plus the v2 row " + f"counted as migrated, before {migrated_before} after {migrated_after}" + ) + assert _model_table_count(gateway, "legacy") == legacy_before + 1 + + +def test_migrate_encryption_runs_under_default_settings_and_rewrites_legacy_rows(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + plaintext_key: Final = f"sk-nacl-{uuid.uuid4().hex}" + model_id, model_name = _insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(plaintext_key)) + migrated: Final = gateway.request("POST", "/credentials/migrate-encryption", {}) + assert migrated.status_code == 200, migrated.text + rewritten: Final = _stored_api_key(model_id) + assert rewritten.startswith(V3_PREFIX), f"expected a {V3_PREFIX} ciphertext, stored {rewritten!r}" + _chat_reaches_upstream_with(gateway, model_name, plaintext_key) + + +def test_undecryptable_model_row_does_not_stop_other_models_from_serving(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + _insert_model_row(gateway, scenario, base64.b64encode(os.urandom(48)).decode()) + healthy_key: Final = f"sk-upstream-{uuid.uuid4().hex}" + _, healthy_model = _new_model(gateway, scenario, healthy_key) + _chat_reaches_upstream_with(gateway, healthy_model, healthy_key) + assert gateway.request("GET", "/health/liveliness").status_code == 200 + + +def _burst_request(gateway: Gateway, model: str, marker: str) -> tuple[str, int]: + try: + response: Final = gateway.request( + "POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": marker}]} + ) + return marker, response.status_code + except httpx.TransportError: + return marker, 0 + + +def test_proxy_restart_mid_burst_reloads_every_encryption_format_from_the_database( + gateway: Gateway, tmp_path: Path +) -> None: + with gateway.scenario() as scenario: + keys: Final = (f"sk-nacl-{uuid.uuid4().hex}", f"sk-v2-{uuid.uuid4().hex}", f"sk-upstream-{uuid.uuid4().hex}") + _, nacl_model = _insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(keys[0])) + _, v2_model = _insert_model_row(gateway, scenario, _legacy_v2_ciphertext(keys[1])) + _, v3_model = _new_model(gateway, scenario, keys[2]) + models: Final = (nacl_model, v2_model, v3_model) + expected: Final = dict(zip(models, (f"Bearer {key}" for key in keys), strict=True)) + markers: Final = tuple(f"burst-{uuid.uuid4().hex}" for _ in range(24)) + with owned_proxy_process(gateway, tmp_path, {}, workers=2) as first: + for model, key in zip(models, keys, strict=True): + _chat_reaches_upstream_with(first.gateway, model, key) + with ThreadPoolExecutor(max_workers=24) as pool: + futures: Final = tuple( + pool.submit(_burst_request, first.gateway, models[index % 3], marker) + for index, marker in enumerate(markers) + ) + eventually(lambda: sum(future.done() for future in futures), lambda done: done >= 6, seconds=60) + os.killpg(first.process.pid, signal.SIGTERM) + outcomes: Final = tuple(future.result(timeout=120) for future in futures) + served: Final = frozenset(marker for marker, status in outcomes if status == 200) + assert served, outcomes + observed: Final = _drain_upstream(gateway) + for index, marker in enumerate(markers): + seen = _authorizations_for(observed, marker) + assert len(seen) <= 1, (marker, seen) + if marker in served: + assert seen == (expected[models[index % 3]],), (marker, seen) + with owned_proxy_process(gateway, tmp_path, {}, workers=2) as restarted: + for model, key in zip(models, keys, strict=True): + _chat_reaches_upstream_with(restarted.gateway, model, key) diff --git a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py index bd310339a1d..c181486c8d4 100644 --- a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py +++ b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py @@ -50,7 +50,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import open_refresh_envelope, user_identity, ) -from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value +from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_if_encrypted_with, encrypt_value _NOW = datetime(2026, 7, 9, 12, 0, 0, tzinfo=timezone.utc) _SIGNING_KEY = "unit-test-signing-key-0123456789abcdef0123456789abcdef" @@ -136,7 +136,8 @@ def test_minimal_grant_round_trips_without_none_leakage_into_claims(): claims = _unverified_claims(token) blob = claims["grant"] assert isinstance(blob, str) - plaintext = decrypt_value(value=base64.urlsafe_b64decode(blob), signing_key=_ENCRYPTION_KEY) + plaintext = decrypt_if_encrypted_with(blob, _ENCRYPTION_KEY) + assert plaintext is not None assert set(json.loads(plaintext)) == {"access_token", "token_type"} opened = open_envelope(token, _KEYS, _NOW) assert isinstance(opened, OpenedEnvelope) diff --git a/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py index 2e255ddf853..86286b9cfd6 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py @@ -4,7 +4,7 @@ Tests for the encrypted-at-rest persistence of MCP user credentials. The ``LiteLLM_MCPUserCredentials.credential_b64`` column previously stored both BYOK API keys and OAuth2 access tokens as plain ``urlsafe_b64encode`` of the raw value, leaving credentials readable from any DB read. The fix -runs every write through ``encrypt_value_helper`` (nacl SecretBox) and +runs every write through ``encrypt_value_helper`` (AES-256-GCM by default) and keeps a plain-base64 fallback on read so existing rows continue to work. """ @@ -515,7 +515,7 @@ async def test_secret_maps_create_update_round_trip(map_algorithm: str, field: s created: Final = await create_mcp_server(prisma, create, touched_by="test") first: Final = table.rows["srv-map"][field] assert isinstance(first, str) and isinstance(json.loads(first), str) - assert json.loads(first).startswith("v2:gcm:") is (map_algorithm == "aes-256-gcm") + assert json.loads(first).startswith("v3:gcm:") is (map_algorithm == "aes-256-gcm") assert "sensitive-secret" not in first and "TEMPLATE" not in first assert getattr(created, field) == original == getattr(create, field) assert decode_secret_map(first, key=field) == original diff --git a/tests/unit/proxy/common_utils/test_credential_hydration.py b/tests/unit/proxy/common_utils/test_credential_hydration.py index f40b0114d71..5a811c0cd6c 100644 --- a/tests/unit/proxy/common_utils/test_credential_hydration.py +++ b/tests/unit/proxy/common_utils/test_credential_hydration.py @@ -1,3 +1,6 @@ +import base64 +import hashlib +import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -26,3 +29,32 @@ async def test_authoritative_hydrate_returns_an_encrypted_empty_value_as_empty(m assert resolved is not None assert resolved.credential_values == {"api_base": "", "openai_service_account_id": "user-1"} + + +def _legacy_nacl_ciphertext(plaintext: str, salt_key: str) -> str: + import nacl.secret + + box = nacl.secret.SecretBox(hashlib.sha256(salt_key.encode()).digest()) + return base64.urlsafe_b64encode(bytes(box.encrypt(plaintext.encode()))).decode() + + +@pytest.mark.asyncio +async def test_authoritative_hydrate_without_pynacl_drops_a_legacy_value_instead_of_serving_the_blob(monkeypatch): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-hydration-test-salt") + legacy = _legacy_nacl_ciphertext("sk-legacy-upstream", "sk-hydration-test-salt") + row = { + "credential_name": "openai-legacy", + "credential_values": {"api_key": legacy, "api_base": encrypt_value_helper("https://api.example.test")}, + "credential_info": {"custom_llm_provider": "openai"}, + } + prisma = MagicMock() + prisma.db.litellm_credentialstable.find_unique = AsyncMock(return_value=row) + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + + with patch.object(litellm, "credential_list", []): # test-quality-ok: the row under test must win over memory + resolved = await hydrate_named_credential_authoritative("openai-legacy", prisma) + + assert resolved is not None + assert resolved.credential_values == {"api_base": "https://api.example.test"} + assert legacy not in resolved.credential_values.values() diff --git a/tests/unit/proxy/common_utils/test_encrypt_decrypt_utils.py b/tests/unit/proxy/common_utils/test_encrypt_decrypt_utils.py index 5b7d35c3b46..579fea5af59 100644 --- a/tests/unit/proxy/common_utils/test_encrypt_decrypt_utils.py +++ b/tests/unit/proxy/common_utils/test_encrypt_decrypt_utils.py @@ -1,26 +1,47 @@ """ Tests for the at-rest credential encryption chokepoint. -Covers the AES-256-GCM (``v2:gcm:``) path, the ``encryption_algorithm`` config -gate, and the backward-compatibility guarantees that let legacy XSalsa20-Poly1305 -(nacl) ciphertext and new AES values coexist and decrypt correctly. +Covers the AES-256-GCM default (``v3:gcm:`` written under HKDF-SHA256), the +``encryption_algorithm`` legacy opt-in and its FIPS refusal, and the dual-read +guarantees that keep ``v2:gcm:`` (raw SHA-256 AES) and unprefixed +XSalsa20-Poly1305 (nacl) ciphertext decrypting after the default flip. """ import base64 +import hashlib +import logging +import os import re +import sys import pytest +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM from litellm.proxy import proxy_server from litellm.proxy.common_utils.encrypt_decrypt_utils import ( _V2_GCM_PREFIX, + _V3_GCM_PREFIX, + LegacyEncryptionUnavailableError, + _get_encryption_algorithm, decrypt_bearer_token, decrypt_if_encrypted_with, decrypt_value_helper, encrypt_bearer_token, encrypt_value, encrypt_value_helper, + is_versioned_gcm, + legacy_encryption_available, + require_legacy_reader_for, ) +from litellm.proxy.common_utils.fips import FipsModeError + +_SALT_KEY = "sk-salt-aes-1234" + + +def _use_legacy(monkeypatch): + """Opt the write-time algorithm back into XSalsa20-Poly1305 for the duration of a test.""" + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) def _use_aes(monkeypatch): @@ -28,124 +49,227 @@ def _use_aes(monkeypatch): monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "aes-256-gcm"}) +def _sha256_key(signing_key: str = _SALT_KEY) -> bytes: + return hashlib.sha256(signing_key.encode()).digest() + + +def _hkdf_key(signing_key: str = _SALT_KEY) -> bytes: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"litellm-at-rest-v3").derive( + signing_key.encode() + ) + + +def _v2_ciphertext(plaintext: str, key: bytes) -> str: + """Build a ``v2:gcm:`` value the way the previous release wrote it: AES-GCM under raw SHA-256.""" + nonce = os.urandom(12) + return ( + _V2_GCM_PREFIX + base64.urlsafe_b64encode(nonce + AESGCM(key).encrypt(nonce, plaintext.encode(), None)).decode() + ) + + +def _legacy_nacl_ciphertext(plaintext: str, key: bytes) -> str: + """Build an unprefixed value the way pre-AES releases wrote it: XSalsa20-Poly1305 under raw SHA-256.""" + import nacl.secret + + return base64.urlsafe_b64encode(bytes(nacl.secret.SecretBox(key).encrypt(plaintext.encode()))).decode() + + @pytest.fixture(autouse=True) def _salt_key(monkeypatch): - # Dominant convention in the test_litellm/ tree: set the key via env. - monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-aes-1234") - # Ensure the legacy default is in force unless a test opts into AES. + monkeypatch.setenv("LITELLM_SALT_KEY", _SALT_KEY) + monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False) monkeypatch.setattr(proxy_server, "general_settings", {}) yield -def test_aes_gcm_round_trip(monkeypatch): - """A value written under AES-256-GCM is tagged v2:gcm: and decrypts back.""" - _use_aes(monkeypatch) - +def test_default_write_is_v3_gcm_and_round_trips(): ct = encrypt_value_helper("super-secret") - assert ct.startswith(_V2_GCM_PREFIX) + assert ct.startswith(_V3_GCM_PREFIX), ct assert decrypt_value_helper(ct, key="t") == "super-secret" -def test_default_is_legacy_algorithm(monkeypatch): - """With no config, writes stay on the legacy algorithm (no v2: marker).""" - ct = encrypt_value_helper("legacy-secret") +def test_explicit_aes_setting_writes_v3_gcm(monkeypatch): + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "AES-256-GCM"}) - assert not ct.startswith(_V2_GCM_PREFIX) - assert decrypt_value_helper(ct, key="t") == "legacy-secret" + assert encrypt_value_helper("secret").startswith(_V3_GCM_PREFIX) -def test_legacy_nacl_value_still_decrypts_after_flag_flip(monkeypatch): - """A value written under the old algorithm decrypts unchanged once AES is on. - - This is the mixed-format readback guarantee: decrypt is format-detecting, so - flipping the flag forward never strands previously-written data. - """ - legacy = encrypt_value_helper("legacy-secret") # default = xsalsa20 - assert not legacy.startswith(_V2_GCM_PREFIX) - - _use_aes(monkeypatch) - # New writes are now AES, but the old value must still come back. - assert decrypt_value_helper(legacy, key="t") == "legacy-secret" - assert encrypt_value_helper("fresh").startswith(_V2_GCM_PREFIX) - - -def test_v2_prefix_is_idempotent_marker(monkeypatch): - """The migration's skip-check: an already-v2 value is recognized by its prefix. - - Re-encrypting an AES value yields a fresh (different nonce) AES value, but the - prefix is what lets a migration skip already-migrated rows without decrypting. - """ - _use_aes(monkeypatch) +def test_unknown_algorithm_falls_back_to_the_aes_default(monkeypatch): + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "rot13"}) ct = encrypt_value_helper("secret") - assert ct.startswith(_V2_GCM_PREFIX) + assert ct.startswith(_V3_GCM_PREFIX), ct + assert decrypt_value_helper(ct, key="t") == "secret" + + +def test_v3_value_is_aes_gcm_under_hkdf_sha256_not_raw_sha256(): + ct = encrypt_value_helper("hkdf-secret") + raw = base64.urlsafe_b64decode(ct[len(_V3_GCM_PREFIX) :]) + nonce, blob = raw[:12], raw[12:] + + assert AESGCM(_hkdf_key()).decrypt(nonce, blob, None) == b"hkdf-secret" + with pytest.raises(InvalidTag): + AESGCM(_sha256_key()).decrypt(nonce, blob, None) + + +def test_each_v3_write_uses_a_fresh_nonce(): + first, second = encrypt_value_helper("same-secret"), encrypt_value_helper("same-secret") + + assert first != second + assert ( + base64.urlsafe_b64decode(first[len(_V3_GCM_PREFIX) :])[:12] + != base64.urlsafe_b64decode(second[len(_V3_GCM_PREFIX) :])[:12] + ) + + +def test_v2_value_written_under_raw_sha256_still_decrypts(): + legacy_v2 = _v2_ciphertext("v2-secret", _sha256_key()) + + assert decrypt_value_helper(legacy_v2, key="t") == "v2-secret" + assert decrypt_if_encrypted_with(legacy_v2, _SALT_KEY) == "v2-secret" + + +def test_v2_value_is_not_read_with_the_v3_derivation(): + """A v2 body relabelled as v3 must fail: the prefix, not the caller, picks the derivation.""" + v2_body = _v2_ciphertext("v2-secret", _sha256_key())[len(_V2_GCM_PREFIX) :] + + assert decrypt_if_encrypted_with(_V3_GCM_PREFIX + v2_body, _SALT_KEY) is None + assert decrypt_value_helper(_V3_GCM_PREFIX + v2_body, key="t", exception_type="debug") is None + + +def test_legacy_nacl_value_still_decrypts_under_the_aes_default(): + legacy = _legacy_nacl_ciphertext("legacy-secret", _sha256_key()) + assert not is_versioned_gcm(legacy) + + assert decrypt_value_helper(legacy, key="t") == "legacy-secret" + assert decrypt_if_encrypted_with(legacy, _SALT_KEY) == "legacy-secret" + assert encrypt_value_helper("fresh").startswith(_V3_GCM_PREFIX) + + +def test_legacy_opt_in_writes_unprefixed_nacl_that_still_reads_back(monkeypatch): + _use_legacy(monkeypatch) + + ct = encrypt_value_helper("legacy-secret") + + assert not is_versioned_gcm(ct), ct + assert decrypt_value_helper(ct, key="t") == "legacy-secret" + assert _get_encryption_algorithm() == "xsalsa20-poly1305" + + +def test_legacy_opt_in_is_refused_under_fips_mode(monkeypatch): + _use_legacy(monkeypatch) + monkeypatch.setenv("LITELLM_FIPS_MODE", "true") + + with pytest.raises(FipsModeError, match="xsalsa20-poly1305") as refused: + encrypt_value_helper("secret") + assert "aes-256-gcm" in str(refused.value) + + +def test_fips_mode_with_the_default_setting_writes_v3_gcm(monkeypatch): + monkeypatch.setenv("LITELLM_FIPS_MODE", "true") + + ct = encrypt_value_helper("fips-secret") + assert ct.startswith(_V3_GCM_PREFIX), ct + assert decrypt_value_helper(ct, key="t") == "fips-secret" + + +def test_versioned_gcm_values_never_import_nacl(monkeypatch): + legacy_v2 = _v2_ciphertext("v2-secret", _sha256_key()) + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + + assert decrypt_value_helper(encrypt_value_helper("v3-secret"), key="t") == "v3-secret" + assert decrypt_value_helper(legacy_v2, key="t") == "v2-secret" + + +def test_legacy_ciphertext_without_pynacl_logs_the_reencrypt_path_and_never_returns_the_blob(monkeypatch, caplog): + legacy = _legacy_nacl_ciphertext("legacy-secret", _sha256_key()) + assert legacy_encryption_available() + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + assert not legacy_encryption_available() + + with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"): + assert decrypt_value_helper(legacy, key="t", exception_type="debug", return_original_value=True) is None + assert decrypt_value_helper(legacy, key="t", exception_type="debug") is None + assert decrypt_if_encrypted_with(legacy, _SALT_KEY) is None + assert len(caplog.records) == 3, caplog.text + for record in caplog.records: + message = record.getMessage() + assert "PyNaCl is not installed" in message + assert "legacy-encryption" in message + assert "/credentials/migrate-encryption" in message + assert "legacy-secret" not in caplog.text + + +def test_require_legacy_reader_for_refuses_only_unprefixed_values_without_pynacl(monkeypatch): + legacy = _legacy_nacl_ciphertext("legacy-secret", _sha256_key()) + v3 = encrypt_value_helper("v3-secret") + require_legacy_reader_for((legacy, v3, "plain"), "rotate the master key") + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + + require_legacy_reader_for((v3, "", None, 3), "rotate the master key") + for unreadable in (legacy, "plain-or-legacy"): + with pytest.raises(LegacyEncryptionUnavailableError, match=r"rotate the master key.*legacy-encryption"): + require_legacy_reader_for((v3, unreadable), "rotate the master key") + + +def test_legacy_opt_in_without_pynacl_fails_the_write_not_silently(monkeypatch): + _use_legacy(monkeypatch) + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + + with pytest.raises(LegacyEncryptionUnavailableError, match="encrypt"): + encrypt_value_helper("secret") + + +def test_v3_prefix_is_the_idempotent_migration_marker(): + ct = encrypt_value_helper("secret") + assert is_versioned_gcm(ct) - # Round-tripping does not change the plaintext, and the marker is stable. again = encrypt_value_helper(decrypt_value_helper(ct, key="t")) - assert again.startswith(_V2_GCM_PREFIX) + assert again.startswith(_V3_GCM_PREFIX) assert decrypt_value_helper(again, key="t") == "secret" -def test_aes_decrypt_failure_returns_none_not_raise(monkeypatch): - """Decrypt contract preserved: a garbled v2 value returns None, never raises.""" - _use_aes(monkeypatch) - - garbled = _V2_GCM_PREFIX + "not-valid-base64-or-ciphertext!!!" - # exception_type="debug" exercises the swallow path; must not raise. +@pytest.mark.parametrize("prefix", [_V3_GCM_PREFIX, _V2_GCM_PREFIX]) +def test_aes_decrypt_failure_returns_none_not_raise(prefix: str): + garbled = prefix + "not-valid-base64-or-ciphertext!!!" assert decrypt_value_helper(garbled, key="t", exception_type="debug") is None -def test_aes_decrypt_failure_returns_original_when_requested(monkeypatch): - """With return_original_value=True a bad v2 value comes back as-is, not None.""" - _use_aes(monkeypatch) - - garbled = _V2_GCM_PREFIX + "###" +def test_aes_decrypt_failure_returns_original_when_requested(): + garbled = _V3_GCM_PREFIX + "###" assert decrypt_value_helper(garbled, key="t", exception_type="debug", return_original_value=True) == garbled -def test_empty_string_round_trips_under_aes(monkeypatch): - """Empty string is preserved through the AES path (parity with legacy).""" - _use_aes(monkeypatch) - +def test_empty_string_round_trips_under_aes(): ct = encrypt_value_helper("") - assert ct.startswith(_V2_GCM_PREFIX) + assert ct.startswith(_V3_GCM_PREFIX) assert decrypt_value_helper(ct, key="t") == "" -def test_callback_prefix_composes_with_v2(monkeypatch): - """litellm_enc:: + v2:gcm:... round-trips through the callback read path. - - Callback vars are stored as ``litellm_enc::``; the read path - strips ``litellm_enc::`` then calls the helper, so the value handed to the - helper is ``v2:gcm:...``. Ordering must work end to end. - """ +def test_callback_prefix_composes_with_v3(): + """litellm_enc:: + v3:gcm:... round-trips through the callback read path.""" from litellm.proxy.common_utils.callback_utils import ( _CALLBACK_VAR_ENCRYPTED_PREFIX, _decrypt_or_passthrough, _encrypt_if_plaintext, ) - _use_aes(monkeypatch) - - # "gcs_path_service_account" is a known-sensitive callback key. stored = _encrypt_if_plaintext("gcs_path_service_account", "my-sa-secret") assert stored.startswith(_CALLBACK_VAR_ENCRYPTED_PREFIX) inner = stored[len(_CALLBACK_VAR_ENCRYPTED_PREFIX) :] - assert inner.startswith(_V2_GCM_PREFIX) + assert inner.startswith(_V3_GCM_PREFIX) assert _decrypt_or_passthrough("gcs_path_service_account", stored) == "my-sa-secret" -def test_unknown_algorithm_falls_back_to_legacy(monkeypatch): - """An unrecognized encryption_algorithm value does not produce v2 writes.""" - monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "rot13"}) - - ct = encrypt_value_helper("secret") - assert not ct.startswith(_V2_GCM_PREFIX) - assert decrypt_value_helper(ct, key="t") == "secret" - - def test_decrypt_failure_debug_log_omits_raw_value(monkeypatch): """Regression for LIT-4152: the decrypt-failure debug breadcrumb must not embed the raw value. @@ -194,10 +318,10 @@ def test_decrypt_failure_debug_log_omits_raw_value(monkeypatch): assert result == secret -@pytest.mark.parametrize("use_aes", [False, True]) -def test_explicit_key_decrypt_reads_only_values_written_under_that_key(monkeypatch, use_aes: bool): - if use_aes: - _use_aes(monkeypatch) +@pytest.mark.parametrize("use_legacy", [False, True]) +def test_explicit_key_decrypt_reads_only_values_written_under_that_key(monkeypatch, use_legacy: bool): + if use_legacy: + _use_legacy(monkeypatch) written_with_previous_key = encrypt_value_helper("stored-secret", new_encryption_key="sk-1234") assert decrypt_if_encrypted_with(written_with_previous_key, "sk-1234") == "stored-secret" @@ -212,6 +336,7 @@ def test_explicit_key_decrypt_reads_only_values_written_under_that_key(monkeypat "gpt-5.4-mini", "https://example.invalid/v1", "v2:gcm:", + "v3:gcm:", "aGVsbG8=", "*", "-", @@ -227,10 +352,10 @@ def test_explicit_key_decrypt_rejects_values_that_are_not_ciphertexts(not_a_ciph assert decrypt_if_encrypted_with(not_a_ciphertext, "sk-1234") is None -@pytest.mark.parametrize("use_aes", [False, True]) -def test_explicit_key_decrypt_tells_an_encrypted_empty_string_from_no_ciphertext(monkeypatch, use_aes: bool): - if use_aes: - _use_aes(monkeypatch) +@pytest.mark.parametrize("use_legacy", [False, True]) +def test_explicit_key_decrypt_tells_an_encrypted_empty_string_from_no_ciphertext(monkeypatch, use_legacy: bool): + if use_legacy: + _use_legacy(monkeypatch) assert decrypt_if_encrypted_with(encrypt_value_helper("", new_encryption_key="sk-1234"), "sk-1234") == "" diff --git a/tests/unit/proxy/management_endpoints/test_credential_migration.py b/tests/unit/proxy/management_endpoints/test_credential_migration.py index ac5a45499d3..070556d83b2 100644 --- a/tests/unit/proxy/management_endpoints/test_credential_migration.py +++ b/tests/unit/proxy/management_endpoints/test_credential_migration.py @@ -8,6 +8,7 @@ proof-of-fix (real proxy + DB) is performed separately on the repro server. import asyncio import json +import sys from types import SimpleNamespace from typing import Final from unittest.mock import AsyncMock, MagicMock @@ -17,7 +18,8 @@ import pytest from litellm._service_logger import ServiceTypes from litellm.proxy import proxy_server from litellm.proxy.common_utils.encrypt_decrypt_utils import ( - _V2_GCM_PREFIX, + _V3_GCM_PREFIX, + encrypt_secret_map, encrypt_value_helper, ) from litellm.proxy.management_endpoints import credential_migration as cm @@ -32,8 +34,8 @@ def salt_key(monkeypatch): def _legacy_ct(value: str, monkeypatch) -> str: - """Produce a legacy (nacl) ciphertext with the AES gate off.""" - monkeypatch.setattr(proxy_server, "general_settings", {}) + """Produce a legacy (nacl) ciphertext through the explicit xsalsa20-poly1305 opt-in.""" + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) return encrypt_value_helper(value) @@ -82,7 +84,7 @@ def test_reencrypt_value_legacy_to_v2(salt_key, monkeypatch): out = cm.reencrypt_value(legacy) assert out != legacy - assert out.startswith(_V2_GCM_PREFIX) + assert out.startswith(_V3_GCM_PREFIX) def test_reencrypt_value_is_idempotent(salt_key, monkeypatch): @@ -113,7 +115,7 @@ def test_reencrypt_selective_dict(salt_key, monkeypatch): data = {"api_key": legacy_key, "base_url": "https://x", "integration_token": None} out = cm.reencrypt_selective_dict(data, ["api_key", "integration_token"]) - assert out["api_key"].startswith(_V2_GCM_PREFIX) + assert out["api_key"].startswith(_V3_GCM_PREFIX) assert out["base_url"] == "https://x" # untouched non-sensitive assert out["integration_token"] is None # null skipped @@ -123,13 +125,120 @@ def test_reencrypt_selective_dict(salt_key, monkeypatch): @pytest.mark.asyncio async def test_migrate_requires_aes_gate(salt_key, monkeypatch): - monkeypatch.setattr(proxy_server, "general_settings", {}) # gate off + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) # legacy opt-in with pytest.raises(RuntimeError, match="encryption_algorithm"): await cm.migrate_encryption( prisma_client=MagicMock(), user_api_key_dict=MagicMock() ) +@pytest.mark.asyncio +async def test_migrate_and_check_refuse_without_pynacl_instead_of_miscounting_legacy_rows(salt_key, monkeypatch): + legacy: Final = _legacy_ct("model-secret", monkeypatch) + _enable_aes(monkeypatch) + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + client: Final = MagicMock() + _empty_covered_tables(client) + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.update = AsyncMock() + + v3_only: Final = await cm.check_encryption(prisma_client=client) + assert v3_only.residual_legacy == 0 + + client.db.litellm_proxymodeltable.find_many = AsyncMock( + return_value=[SimpleNamespace(litellm_params={"api_key": legacy})] + ) + client.db.litellm_proxymodeltable.update_many = AsyncMock() + with pytest.raises(RuntimeError, match="legacy-encryption"): + await cm.check_encryption(prisma_client=client) + with pytest.raises(RuntimeError, match="legacy-encryption"): + await cm.migrate_encryption(prisma_client=client, user_api_key_dict=MagicMock()) + client.db.litellm_proxymodeltable.update_many.assert_not_awaited() + client.db.litellm_config.update.assert_not_awaited() + + +@pytest.mark.parametrize( + "db_attr, build_row", + [ + ("litellm_mcpservertable", lambda ct: SimpleNamespace(server_id="s1", static_headers=json.dumps(ct))), + ("litellm_mcpservertable", lambda ct: SimpleNamespace(server_id="s1", env=ct)), + ("litellm_mcpserveroauthclient", lambda ct: SimpleNamespace(server_id="s1", credentials={"client_secret": ct})), + ("litellm_ssoidentityassertion", lambda ct: SimpleNamespace(user_id="u1", assertion_b64=ct)), + ], +) +@pytest.mark.asyncio +async def test_check_refuses_without_pynacl_for_every_rotation_rewritten_location( + db_attr, build_row, salt_key, monkeypatch +): + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) + legacy_map: Final = json.loads(encrypt_secret_map({"Authorization": "Bearer legacy"})) + legacy_value: Final = encrypt_value_helper("legacy-secret") + _enable_aes(monkeypatch) + client: Final = MagicMock() + _empty_covered_tables(client) + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + ciphertext: Final = legacy_map if db_attr == "litellm_mcpservertable" else legacy_value + getattr(client.db, db_attr).find_many = AsyncMock(return_value=[build_row(ciphertext)]) + + with_pynacl: Final = await cm.check_encryption(prisma_client=client) + assert with_pynacl.residual_legacy == 1, with_pynacl.to_dict() + + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + with pytest.raises(RuntimeError, match="legacy-encryption"): + await cm.check_encryption(prisma_client=client) + + +@pytest.mark.asyncio +async def test_check_without_pynacl_ignores_plaintext_mcp_metadata_next_to_v3_secrets(salt_key, monkeypatch): + _enable_aes(monkeypatch) + v3_secret: Final = encrypt_value_helper("dcr-secret") + client: Final = MagicMock() + _empty_covered_tables(client) + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + client.db.litellm_mcpserveroauthclient.find_many = AsyncMock( + return_value=[ + SimpleNamespace( + server_id="s1", + credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"}, + ) + ] + ) + client.db.litellm_mcpservertable.find_many = AsyncMock( + return_value=[ + SimpleNamespace( + server_id="s1", + credentials={"auth_value": v3_secret, "auth_type": "api_key", "token_url": "https://idp/token"}, + env_vars=[ + {"name": "TOKEN", "scope": "global", "value": v3_secret}, + {"name": "USER_TOKEN", "scope": "user", "value": "{{user.token}}"}, + ], + ) + ] + ) + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + + report: Final = await cm.check_encryption(prisma_client=client) + + by_location: Final = report.as_dict()["locations"] + assert report.residual_legacy == 0, by_location + assert by_location["mcp_oauth_client"]["already_v2"] == 2, by_location + assert by_location["mcp_oauth_client"]["scanned"] == 2, by_location + assert by_location["mcp_server"]["already_v2"] == 2, by_location + assert by_location["mcp_server"]["scanned"] == 2, by_location + + # --------------------------- config-row walker --------------------------- @@ -164,7 +273,7 @@ async def test_vantage_walker_migrates_legacy_field(salt_key, monkeypatch): written = json.loads( client.db.litellm_config.update.call_args.kwargs["data"]["param_value"] ) - assert written["api_key"].startswith(_V2_GCM_PREFIX) + assert written["api_key"].startswith(_V3_GCM_PREFIX) assert written["base_url"] == "https://api.vantage.sh" # non-sensitive untouched @@ -308,8 +417,8 @@ async def test_callback_vars_walker_migrates_team_metadata(salt_key, monkeypatch """A team row with a legacy-encrypted callback var is rewritten to v2.""" from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars - # Legacy-encrypt a callback var via the real callback path (gate off). - monkeypatch.setattr(proxy_server, "general_settings", {}) + # Legacy-encrypt a callback var via the real callback path (legacy opt-in). + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) legacy_meta = encrypt_callback_vars( {"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]} ) @@ -329,7 +438,7 @@ async def test_callback_vars_walker_migrates_team_metadata(salt_key, monkeypatch client.db.litellm_teamtable.update.call_args.kwargs["data"]["metadata"] ) inner = written["logging"][0]["callback_vars"]["gcs_path_service_account"] - assert "v2:gcm:" in inner + assert "v3:gcm:" in inner @pytest.mark.asyncio @@ -337,7 +446,7 @@ async def test_callback_vars_walker_dry_run_reports_legacy(salt_key, monkeypatch """In --check (dry-run) mode, a legacy callback var counts as residual legacy.""" from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars - monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) legacy_meta = encrypt_callback_vars( {"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]} ) @@ -369,7 +478,7 @@ async def test_callback_vars_walker_migrates_callback_settings_shape( """ from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars - monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) legacy_meta = encrypt_callback_vars( { "callback_settings": { @@ -394,7 +503,7 @@ async def test_callback_vars_walker_migrates_callback_settings_shape( client.db.litellm_teamtable.update.call_args.kwargs["data"]["metadata"] ) inner = written["callback_settings"]["callback_vars"]["gcs_path_service_account"] - assert "v2:gcm:" in inner + assert "v3:gcm:" in inner @pytest.mark.asyncio @@ -404,13 +513,13 @@ async def test_check_reports_callback_var_legacy_with_gate_off(salt_key, monkeyp Detection is decrypt-based, not a re-encrypt delta, so it does not depend on the write gate. A heuristic that re-encrypts and counts new v2 values would - read zero here (gate off -> no v2 produced) and emit a false-clean + read zero here (legacy opt-in -> no versioned AES produced) and emit a false-clean attestation -- exactly the compliance trap this guards against. """ from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars - # Legacy-encrypt a callback var, and leave the gate OFF for the check itself. - monkeypatch.setattr(proxy_server, "general_settings", {}) + # Legacy-encrypt a callback var, and keep the legacy opt-in for the check itself. + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) legacy_meta = encrypt_callback_vars( {"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]} ) @@ -453,7 +562,7 @@ async def test_scan_covered_tables_classifies_legacy_and_v2(salt_key, monkeypatc ) client.db.litellm_config.find_unique = AsyncMock(return_value=None) - by_loc = {r.location: r for r in await cm._scan_covered_tables(client)} + by_loc = {r.location: r for r in await cm.scan_covered_tables(client)} assert by_loc["model_table"].legacy == 1 assert by_loc["model_table"].plaintext == 1 # "gpt-4" model name, not ciphertext @@ -477,7 +586,7 @@ async def test_scan_covered_tables_classifies_search_tool_params(salt_key, monke ) client.db.litellm_config.find_unique = AsyncMock(return_value=None) - by_loc = {r.location: r for r in await cm._scan_covered_tables(client)} + by_loc = {r.location: r for r in await cm.scan_covered_tables(client)} assert (by_loc["search_tools"].legacy, by_loc["search_tools"].already_v2) == (1, 1) assert by_loc["search_tools"].plaintext == 1 diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index 15bf4f31445..725252ae540 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -9430,6 +9430,110 @@ async def test_rotate_master_key_reencrypts_model_params_in_place( ), "api_key must be stored re-encrypted under the new master key, not in plaintext" +@pytest.mark.parametrize( + "legacy_table, build_row", + [ + ("litellm_proxymodeltable", lambda ct: SimpleNamespace(litellm_params={"api_key": ct})), + ("litellm_mcpserveroauthclient", lambda ct: SimpleNamespace(server_id="s1", credentials={"client_secret": ct})), + ("litellm_ssoidentityassertion", lambda ct: SimpleNamespace(user_id="u1", assertion_b64=ct)), + ], +) +async def test_rotate_master_key_refuses_without_pynacl_before_touching_any_row(legacy_table, build_row, monkeypatch): + import sys + from unittest.mock import AsyncMock, MagicMock + + from fastapi import HTTPException + + from litellm.proxy import proxy_server + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _rotate_master_key, + ) + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-rotate") + monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) + legacy_row = build_row(encrypt_value_helper("legacy-secret")) + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + mock_prisma_client = AsyncMock() + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_config.find_unique = AsyncMock(return_value=None) + covered_tables = ( + "litellm_proxymodeltable", + "litellm_credentialstable", + "litellm_mcpservertable", + "litellm_mcpusercredentials", + "litellm_mcpuserenvvars", + "litellm_mcpserveroauthclient", + "litellm_ssoidentityassertion", + ) + for table in covered_tables: + rows = [legacy_row] if table == legacy_table else [] + getattr(mock_prisma_client.db, table).find_many = AsyncMock(return_value=rows) + getattr(mock_prisma_client.db, table).update = AsyncMock() + getattr(mock_prisma_client.db, table).update_many = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await _rotate_master_key( + prisma_client=mock_prisma_client, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user"), + current_master_key="sk-old-master-key", + new_master_key="sk-new-master-key", + ) + + assert exc_info.value.status_code == 400 + assert "legacy-encryption" in exc_info.value.detail["error"] + for table in covered_tables: + getattr(mock_prisma_client.db, table).update.assert_not_awaited() + getattr(mock_prisma_client.db, table).update_many.assert_not_awaited() + mock_prisma_client.db.tx.assert_not_called() + + +async def test_rotate_master_key_preflight_without_pynacl_passes_migrated_oauth_client_with_plaintext_scopes( + monkeypatch, +): + import sys + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy import proxy_server + from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _require_legacy_reader_for_stored_values, + ) + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-rotate") + monkeypatch.setattr(proxy_server, "general_settings", {}) + v3_secret = encrypt_value_helper("dcr-secret") + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + mock_prisma_client = AsyncMock() + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_config.find_unique = AsyncMock(return_value=None) + for table in ( + "litellm_proxymodeltable", + "litellm_credentialstable", + "litellm_mcpservertable", + "litellm_mcpusercredentials", + "litellm_mcpuserenvvars", + "litellm_ssoidentityassertion", + ): + getattr(mock_prisma_client.db, table).find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_mcpserveroauthclient.find_many = AsyncMock( + return_value=[ + SimpleNamespace( + server_id="s1", + credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"}, + ) + ] + ) + + await _require_legacy_reader_for_stored_values(mock_prisma_client) + + assert mock_prisma_client.db.litellm_mcpserveroauthclient.find_many.await_count == 1 + + async def test_default_key_generate_params_duration(monkeypatch): """ Test that default_key_generate_params with 'duration' is applied diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index c8979a07b40..3f717e9a368 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -13,6 +13,7 @@ import json import logging import os import re +import sys from collections.abc import Mapping from contextlib import nullcontext from dataclasses import dataclass @@ -27,7 +28,11 @@ from pydantic import JsonValue, TypeAdapter, ValidationError import litellm from litellm.proxy._types import CommonProxyErrors -from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + LegacyEncryptionUnavailableError, + decrypt_value_helper, + encrypt_value_helper, +) from litellm.proxy.proxy_server import ( ProxyConfig, _is_remote_module_url, @@ -3343,6 +3348,28 @@ def test_ProxyConfig_decrypt_credentials_returns_an_encrypted_empty_value_as_emp assert decrypted.credential_values == {"api_base": "", "openai_service_account_id": "user-1"} +def test_ProxyConfig_decrypt_credentials_without_pynacl_drops_a_legacy_value_instead_of_serving_the_blob(monkeypatch): + import base64 + import hashlib + + import nacl.secret + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-decrypt-credentials-test-salt") + box = nacl.secret.SecretBox(hashlib.sha256(b"sk-decrypt-credentials-test-salt").digest()) + legacy = base64.urlsafe_b64encode(bytes(box.encrypt(b"sk-legacy-upstream"))).decode() + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + + decrypted = ProxyConfig().decrypt_credentials( + { + "credential_name": "openai-legacy", + "credential_values": {"api_key": legacy, "api_base": encrypt_value_helper("https://api.example.test")}, + "credential_info": {"custom_llm_provider": "openai"}, + } + ) + assert decrypted.credential_values == {"api_base": "https://api.example.test"} + + def test_ProxyConfig_decrypt_model_list_from_db_returns_decrypted(monkeypatch): monkeypatch.setattr( "litellm.proxy.proxy_server.decrypt_value_helper", @@ -3688,6 +3715,22 @@ def test_ProxyConfig__encrypt_env_variables_for_db_idempotent(monkeypatch): assert out == {"A": "ENC[1]", "B": "ENC[2]", "C": "ENC[3]"} +def test_ProxyConfig__encrypt_env_variables_for_db_refuses_without_pynacl_instead_of_dropping_legacy_values( + monkeypatch, +): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-config-save") + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + v3 = encrypt_value_helper("already-migrated") + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + pc = ProxyConfig() + + saved = pc._encrypt_env_variables_for_db({"A": v3}) + assert decrypt_value_helper(saved["A"], key="A") == "already-migrated" + with pytest.raises(LegacyEncryptionUnavailableError, match=r"config save.*legacy-encryption"): + pc._encrypt_env_variables_for_db({"A": v3, "B": "plain-or-legacy"}) + + def test_ProxyConfig__encrypt_env_variables_for_db_invalid_raises(): pc = ProxyConfig() with pytest.raises(AttributeError): diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bc407c30c70..efcf485d9ea 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -3751,9 +3751,9 @@ export interface paths { put?: never; /** * Migrate Encryption Endpoint - * @description Re-encrypt all at-rest credentials into the AES-256-GCM (``v2:gcm:``) format. + * @description Re-encrypt all at-rest credentials into the versioned AES-256-GCM (``v3:gcm:``) format. * - * Admin only. Requires ``general_settings.encryption_algorithm: aes-256-gcm``. + * Admin only. Requires the proxy to write ``aes-256-gcm`` (the default). * Idempotent and resumable — re-running skips already-migrated values. Pass * ``dry_run=true`` for a non-mutating scan (equivalent to ``--check``). */ diff --git a/uv.lock b/uv.lock index a5438188d4a..6104856c50b 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-10-02T08:09:01.060139Z" exclude-newer-span = "P3D" [manifest] @@ -225,9 +225,9 @@ name = "aiologic" version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a7/809482759f40079f4c4328c7318bf569ae25d457f5017aad30a1b9aafedc/aiologic-0.17.0.tar.gz", hash = "sha256:65aa058e858c94cd208badb188e7f00b54dcabb3ba85b34f794db98074d108b9", size = 251625, upload-time = "2026-06-14T12:24:35.367Z" } wheels = [ @@ -519,14 +519,14 @@ name = "aurelio-sdk" version = "0.0.19" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiofiles" }, - { name = "aiohttp" }, - { name = "colorlog" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "tornado" }, + { name = "aiofiles", marker = "python_full_version < '3.14'" }, + { name = "aiohttp", marker = "python_full_version < '3.14'" }, + { name = "colorlog", marker = "python_full_version < '3.14'" }, + { name = "pydantic", marker = "python_full_version < '3.14'" }, + { name = "python-dotenv", marker = "python_full_version < '3.14'" }, + { name = "requests", marker = "python_full_version < '3.14'" }, + { name = "requests-toolbelt", marker = "python_full_version < '3.14'" }, + { name = "tornado", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/27/0e/c2e369ad173fb3d76448e46d10beb3dcc53388318933ddf8169a3f21a810/aurelio_sdk-0.0.19.tar.gz", hash = "sha256:14107e7440ff2efd0b4a08c52fb595e7680bd4bc973a0ddfb3b64157c6666b91", size = 15258, upload-time = "2025-03-24T14:37:32.203Z" } wheels = [ @@ -538,9 +538,9 @@ name = "aws-sdk-bedrock-runtime" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "smithy-aws-core", extra = ["eventstream", "json"] }, - { name = "smithy-core" }, - { name = "smithy-http", extra = ["aiohttp"] }, + { name = "smithy-aws-core", extra = ["eventstream", "json"], marker = "python_full_version >= '3.12'" }, + { name = "smithy-core", marker = "python_full_version >= '3.12'" }, + { name = "smithy-http", extra = ["aiohttp"], marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/b3/9c225cbfe9f17ea2e3d75a0fdd0b325ef79839b9c09a376bda63a7bf3bb3/aws_sdk_bedrock_runtime-0.11.0.tar.gz", hash = "sha256:f2c45d34625bf6a7b56375e29a53a16b376880bda771e4bbf7d84491622eb193", size = 173854, upload-time = "2026-08-24T21:17:16.304Z" } wheels = [ @@ -549,7 +549,7 @@ wheels = [ [package.optional-dependencies] awscrt = [ - { name = "smithy-http", extra = ["awscrt"] }, + { name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, ] [[package]] @@ -1204,7 +1204,7 @@ name = "colorlog" version = "6.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } wheels = [ @@ -1228,7 +1228,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -1301,7 +1301,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } @@ -1571,8 +1571,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -1826,7 +1826,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2409,11 +2409,11 @@ resolution-markers = [ "python_full_version >= '3.14'", ] dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, + { name = "google-auth", marker = "python_full_version >= '3.14'" }, + { name = "googleapis-common-protos", marker = "python_full_version >= '3.14'" }, + { name = "proto-plus", marker = "python_full_version >= '3.14'" }, + { name = "protobuf", marker = "python_full_version >= '3.14'" }, + { name = "requests", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" } wheels = [ @@ -2422,8 +2422,8 @@ wheels = [ [package.optional-dependencies] grpc = [ - { name = "grpcio" }, - { name = "grpcio-status" }, + { name = "grpcio", marker = "python_full_version >= '3.14'" }, + { name = "grpcio-status", marker = "python_full_version >= '3.14'" }, ] [[package]] @@ -2437,11 +2437,11 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, + { name = "google-auth", marker = "python_full_version < '3.14'" }, + { name = "googleapis-common-protos", marker = "python_full_version < '3.14'" }, + { name = "proto-plus", marker = "python_full_version < '3.14'" }, + { name = "protobuf", marker = "python_full_version < '3.14'" }, + { name = "requests", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } wheels = [ @@ -2450,8 +2450,8 @@ wheels = [ [package.optional-dependencies] grpc = [ - { name = "grpcio" }, - { name = "grpcio-status" }, + { name = "grpcio", marker = "python_full_version < '3.14'" }, + { name = "grpcio-status", marker = "python_full_version < '3.14'" }, ] [[package]] @@ -2637,12 +2637,12 @@ resolution-markers = [ "python_full_version >= '3.14'", ] dependencies = [ - { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" } }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, + { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "google-auth", marker = "python_full_version >= '3.14'" }, + { name = "google-cloud-core", marker = "python_full_version >= '3.14'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.14'" }, + { name = "google-resumable-media", marker = "python_full_version >= '3.14'" }, + { name = "requests", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bd/ef/7cefdca67a6c8b3af0ec38612f9e78e5a9f6179dd91352772ae1a9849246/google_cloud_storage-3.4.1.tar.gz", hash = "sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268", size = 17238203, upload-time = "2025-10-08T18:43:39.665Z" } wheels = [ @@ -2660,12 +2660,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "google-api-core", version = "2.30.3", source = { registry = "https://pypi.org/simple" } }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, + { name = "google-api-core", version = "2.30.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "google-auth", marker = "python_full_version < '3.14'" }, + { name = "google-cloud-core", marker = "python_full_version < '3.14'" }, + { name = "google-crc32c", marker = "python_full_version < '3.14'" }, + { name = "google-resumable-media", marker = "python_full_version < '3.14'" }, + { name = "requests", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" } wheels = [ @@ -4127,13 +4127,13 @@ name = "langchain-classic" version = "1.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, - { name = "langchain-text-splitters" }, - { name = "langsmith" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'" }, + { name = "langchain-text-splitters", marker = "python_full_version >= '3.11'" }, + { name = "langsmith", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "sqlalchemy", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/78/84b5065816f348c39fefa4316f209f0135e8410216340a953bec17d9e4e4/langchain_classic-1.0.7.tar.gz", hash = "sha256:debbec8065e69b95108d2652e8d5c44f4516e19aa8d716c02ed2211c3aee099d", size = 10554118, upload-time = "2026-05-07T15:46:56.8Z" } wheels = [ @@ -4148,18 +4148,18 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "aiohttp" }, - { name = "dataclasses-json" }, - { name = "httpx-sse" }, - { name = "langchain" }, - { name = "langchain-core" }, - { name = "langsmith" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, - { name = "pydantic-settings" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, - { name = "tenacity" }, + { name = "aiohttp", marker = "python_full_version < '3.11'" }, + { name = "dataclasses-json", marker = "python_full_version < '3.11'" }, + { name = "httpx-sse", marker = "python_full_version < '3.11'" }, + { name = "langchain", marker = "python_full_version < '3.11'" }, + { name = "langchain-core", marker = "python_full_version < '3.11'" }, + { name = "langsmith", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pydantic-settings", marker = "python_full_version < '3.11'" }, + { name = "pyyaml", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "sqlalchemy", marker = "python_full_version < '3.11'" }, + { name = "tenacity", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/49/2ff5354273809e9811392bc24bcffda545a196070666aef27bc6aacf1c21/langchain_community-0.3.31.tar.gz", hash = "sha256:250e4c1041539130f6d6ac6f9386cb018354eafccd917b01a4cff1950b80fd81", size = 33241237, upload-time = "2025-10-07T20:17:57.857Z" } wheels = [ @@ -4177,19 +4177,19 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "aiohttp" }, - { name = "dataclasses-json" }, - { name = "httpx-sse" }, - { name = "langchain-classic" }, - { name = "langchain-core" }, - { name = "langsmith" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "aiohttp", marker = "python_full_version >= '3.11'" }, + { name = "dataclasses-json", marker = "python_full_version >= '3.11'" }, + { name = "httpx-sse", marker = "python_full_version >= '3.11'" }, + { name = "langchain-classic", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'" }, + { name = "langsmith", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic-settings" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, - { name = "tenacity" }, + { name = "pydantic-settings", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "sqlalchemy", marker = "python_full_version >= '3.11'" }, + { name = "tenacity", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/97/a03585d42b9bdb6fbd935282d6e3348b10322a24e6ce12d0c99eb461d9af/langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85", size = 33241144, upload-time = "2025-10-27T15:20:32.504Z" } wheels = [ @@ -4247,7 +4247,7 @@ name = "langchain-text-splitters" version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" } wheels = [ @@ -4557,6 +4557,9 @@ google = [ grpc = [ { name = "grpcio" }, ] +legacy-encryption = [ + { name = "pynacl" }, +] mcp = [ { name = "httpx2" }, { name = "mcp" }, @@ -4587,7 +4590,6 @@ proxy = [ { name = "polars" }, { name = "pydantic" }, { name = "pyjwt" }, - { name = "pynacl" }, { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, { name = "python-multipart" }, { name = "pyyaml" }, @@ -4744,6 +4746,7 @@ proxy-dev = [ { name = "opentelemetry-sdk" }, { name = "prisma" }, { name = "prometheus-client" }, + { name = "pynacl" }, { name = "sentry-sdk" }, ] @@ -4826,7 +4829,7 @@ requires-dist = [ { name = "pydantic", marker = "extra == 'proxy'", specifier = ">=2.12.0,<3" }, { name = "pydantic-settings", specifier = ">=2.14.1,<3.0" }, { name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, - { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" }, + { name = "pynacl", marker = "extra == 'legacy-encryption'", specifier = ">=1.6.2,<2.0" }, { name = "pypdf", marker = "extra == 'proxy-runtime'", specifier = ">=6.16.1,<7.0" }, { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" }, { name = "python-dotenv", specifier = ">=1.0.0,<2.0" }, @@ -4857,7 +4860,7 @@ requires-dist = [ { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.22.1,<1.0" }, { name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" }, ] -provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-vertex-chirp", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"] +provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "legacy-encryption", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-vertex-chirp", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"] [package.metadata.requires-dev] benchmarks = [ @@ -4959,6 +4962,7 @@ proxy-dev = [ { name = "opentelemetry-sdk", specifier = "==1.33.1" }, { name = "prisma", specifier = "==0.11.0" }, { name = "prometheus-client", specifier = "==0.20.0" }, + { name = "pynacl", specifier = ">=1.6.2,<2.0" }, { name = "sentry-sdk", specifier = "==2.21.0" }, ] @@ -5049,16 +5053,16 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "aiohttp" }, - { name = "chevron" }, - { name = "jsonpickle" }, - { name = "langchain-community", version = "0.3.31", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pyhumps" }, - { name = "requests" }, - { name = "setuptools" }, - { name = "tenacity" }, + { name = "aiohttp", marker = "python_full_version < '3.11'" }, + { name = "chevron", marker = "python_full_version < '3.11'" }, + { name = "jsonpickle", marker = "python_full_version < '3.11'" }, + { name = "langchain-community", version = "0.3.31", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pydantic", marker = "python_full_version < '3.11'" }, + { name = "pyhumps", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "setuptools", marker = "python_full_version < '3.11'" }, + { name = "tenacity", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/6f/9ca1acf766848aaf5f0ac4140c34c91ad0dbfad2654359699644be3352c9/lunary-1.4.36.tar.gz", hash = "sha256:53f002f385c83d9c0e6368e7999923acffbde987f53c5205c2c249c38ee2d75c", size = 20253, upload-time = "2026-02-09T20:49:30.56Z" } wheels = [ @@ -5076,16 +5080,16 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "aiohttp" }, - { name = "chevron" }, - { name = "jsonpickle" }, - { name = "langchain-community", version = "0.4.1", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pyhumps" }, - { name = "requests" }, - { name = "setuptools" }, - { name = "tenacity" }, + { name = "aiohttp", marker = "python_full_version >= '3.11'" }, + { name = "chevron", marker = "python_full_version >= '3.11'" }, + { name = "jsonpickle", marker = "python_full_version >= '3.11'" }, + { name = "langchain-community", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "pyhumps", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "setuptools", marker = "python_full_version >= '3.11'" }, + { name = "tenacity", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/ef/1acbc6957585cc0110e648d787663871717ced3df27fcd3cb5e18fa418f3/lunary-1.4.37.tar.gz", hash = "sha256:1781091e9dceffcc28ebc4be7e085c9fec4102d98d7ca945ed0021e9ce03c36f", size = 20248, upload-time = "2026-02-12T08:15:02.091Z" } wheels = [ @@ -8865,10 +8869,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -8915,11 +8919,11 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -8969,7 +8973,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -9031,7 +9035,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } @@ -9116,20 +9120,20 @@ name = "semantic-router" version = "0.1.15" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "aurelio-sdk" }, - { name = "colorama" }, - { name = "colorlog" }, - { name = "litellm" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" }, + { name = "aiohttp", marker = "python_full_version < '3.14'" }, + { name = "aurelio-sdk", marker = "python_full_version < '3.14'" }, + { name = "colorama", marker = "python_full_version < '3.14'" }, + { name = "colorlog", marker = "python_full_version < '3.14'" }, + { name = "litellm", marker = "python_full_version < '3.14'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "tiktoken" }, - { name = "tornado" }, - { name = "urllib3" }, + { name = "openai", marker = "python_full_version < '3.14'" }, + { name = "pydantic", marker = "python_full_version < '3.14'" }, + { name = "pyyaml", marker = "python_full_version < '3.14'" }, + { name = "regex", marker = "python_full_version < '3.14'" }, + { name = "tiktoken", marker = "python_full_version < '3.14'" }, + { name = "tornado", marker = "python_full_version < '3.14'" }, + { name = "urllib3", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/a9/1a689e916e8b280f1fd8fb335cc059be626a22fe4533baa045d32fcd6de5/semantic_router-0.1.15.tar.gz", hash = "sha256:328256ddc3c2b713101ec69561d6585aecbf1198ea3461e1486289d8c3a35288", size = 95605, upload-time = "2026-05-23T12:58:15.444Z" } wheels = [ @@ -9203,9 +9207,9 @@ name = "smithy-aws-core" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aws-sdk-signers" }, - { name = "smithy-core" }, - { name = "smithy-http" }, + { name = "aws-sdk-signers", marker = "python_full_version >= '3.12'" }, + { name = "smithy-core", marker = "python_full_version >= '3.12'" }, + { name = "smithy-http", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/d3/501c0023548173416109ac42298ca33b708469dc922005770811a597949f/smithy_aws_core-0.11.0.tar.gz", hash = "sha256:29ee89976a520a87e3db557e03e115fdc21a0a60b81161e95174395a1b064da1", size = 38791, upload-time = "2026-08-24T21:16:59.631Z" } wheels = [ @@ -9214,10 +9218,10 @@ wheels = [ [package.optional-dependencies] eventstream = [ - { name = "smithy-aws-event-stream" }, + { name = "smithy-aws-event-stream", marker = "python_full_version >= '3.12'" }, ] json = [ - { name = "smithy-json" }, + { name = "smithy-json", marker = "python_full_version >= '3.12'" }, ] [[package]] @@ -9225,7 +9229,7 @@ name = "smithy-aws-event-stream" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "smithy-core" }, + { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/0e/6efb3a4ed92c0f1ada6de060ac92e7115a1e34d0ab1fb99a6056734a88ea/smithy_aws_event_stream-0.3.0.tar.gz", hash = "sha256:a0e227367a973144e205a075d0a424f95c92f26656a1018d08900da2ae547c49", size = 12818, upload-time = "2026-05-05T18:04:14.317Z" } wheels = [ @@ -9246,7 +9250,7 @@ name = "smithy-http" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "smithy-core" }, + { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/78/b5f3113d6c8f0bc1f9777a7f5ca84b892d29efac05850e14f7d4f7e645b5/smithy_http-0.5.0.tar.gz", hash = "sha256:bb4a19672f7c7eeb872a308f777eb505281a5bafb1ee3d1ea9c760c06c352510", size = 31122, upload-time = "2026-08-24T21:16:56.488Z" } wheels = [ @@ -9255,11 +9259,11 @@ wheels = [ [package.optional-dependencies] aiohttp = [ - { name = "aiohttp" }, - { name = "yarl" }, + { name = "aiohttp", marker = "python_full_version >= '3.12'" }, + { name = "yarl", marker = "python_full_version >= '3.12'" }, ] awscrt = [ - { name = "awscrt" }, + { name = "awscrt", marker = "python_full_version >= '3.12'" }, ] [[package]] @@ -9267,8 +9271,8 @@ name = "smithy-json" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ijson" }, - { name = "smithy-core" }, + { name = "ijson", marker = "python_full_version >= '3.12'" }, + { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/ac/04164eefb3da7479f52f6535b4b39cc8384c292cb2bb74279f2acc4f4b4d/smithy_json-0.3.0.tar.gz", hash = "sha256:c81c7034587e01bc64767cbbecb05a7d65ca9070612fd94e8a03e80540290a22", size = 7956, upload-time = "2026-08-20T17:55:32.177Z" } wheels = [ @@ -9346,23 +9350,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli" }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -9377,23 +9381,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -9410,23 +9414,23 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ @@ -9574,8 +9578,8 @@ name = "standard-aifc" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts" }, - { name = "standard-chunk" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "standard-chunk", marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } wheels = [ @@ -9596,7 +9600,7 @@ name = "standard-sunau" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" } wheels = [ @@ -9630,8 +9634,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup" }, - { name = "typing-extensions" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [