Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/common_utils/encrypt_decrypt_utils.py: 74%
147 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1import base64
2import os
3from collections.abc import Mapping
4from typing import Final, Literal, cast
6from pydantic import TypeAdapter, ValidationError
8from litellm._logging import verbose_proxy_logger
10# Versioned ciphertext marker for AES-256-GCM values.
11# Format: "v2:gcm:" + base64url(nonce(12) || ciphertext || tag(16)).
12# Legacy XSalsa20-Poly1305 (nacl) values carry no marker; the colon in the
13# prefix can never appear in base64url(nacl output), so the prefix check is an
14# unambiguous discriminator between the two formats on read.
15_V2_GCM_PREFIX: Final = "v2:gcm:"
17# general_settings key selecting the at-rest encryption algorithm for new writes.
18# Default preserves the legacy algorithm so existing deployments are byte-for-byte
19# unchanged until they explicitly opt in. Decrypt is always format-detecting, so
20# flipping this flag forward (or back) never strands previously-written data.
21_ENCRYPTION_ALGORITHM_SETTING: Final = "encryption_algorithm"
22_ALGO_AES_GCM: Final = "aes-256-gcm"
23_ALGO_XSALSA20: Final = "xsalsa20-poly1305"
26def _get_salt_key():
27 from litellm.proxy.proxy_server import master_key
29 salt_key = os.getenv("LITELLM_SALT_KEY", None)
31 if salt_key is None: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true
32 salt_key = master_key
34 return salt_key
37def _get_encryption_algorithm() -> str:
38 """
39 Resolve the configured at-rest encryption algorithm for *new writes*.
41 Read from ``general_settings.encryption_algorithm`` at write time. Defaults to
42 the legacy XSalsa20-Poly1305 algorithm so deployments that have not opted in
43 keep producing byte-for-byte identical ciphertext.
44 """
45 try:
46 from litellm.proxy.proxy_server import general_settings
48 algo: Final = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING, _ALGO_XSALSA20)
49 except Exception:
50 # general_settings may not be importable in some contexts (e.g. SDK-only
51 # use of these helpers). Fall back to the legacy algorithm.
52 return _ALGO_XSALSA20
54 if isinstance(algo, str) and algo.lower() == _ALGO_AES_GCM: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 return _ALGO_AES_GCM
56 return _ALGO_XSALSA20
59def _derive_key(signing_key: str) -> bytes:
60 """Derive a 32-byte key from the salt/master key (shared by both algorithms).
62 Known limitation: this is a single-pass, unsalted ``SHA-256`` of the key, not
63 a dedicated KDF (HKDF/PBKDF2). It is the *same* derivation the legacy nacl
64 path already uses, so the AES path introduces no new weakness and stays
65 interoperable with existing key sourcing; AES-256-GCM's per-value 12-byte
66 random nonce gives the unique (key, nonce) pairs GCM requires. Moving both
67 algorithms to HKDF-SHA256 would be more defensible in an audit but is a
68 separate, coordinated change (it must re-derive or re-encrypt existing data).
69 """
70 import hashlib
72 return hashlib.sha256(signing_key.encode()).digest()
75def _seal_aes_gcm(value: str, signing_key: str, aad: bytes | None) -> bytes:
76 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
78 nonce: Final = os.urandom(12)
79 # AESGCM.encrypt returns ciphertext || tag(16); wire format is nonce || that.
80 return nonce + AESGCM(_derive_key(signing_key)).encrypt(nonce, value.encode("utf-8"), aad)
83def _open_aes_gcm(sealed: bytes, signing_key: str, aad: bytes | None) -> str:
84 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
86 # An empty plaintext still serializes to nonce(12) || tag(16) = 28 bytes, so a
87 # short/empty buffer here is a corrupt value: let AESGCM.decrypt raise and be
88 # swallowed by the caller (returns None/original), same as legacy.
89 return AESGCM(_derive_key(signing_key)).decrypt(sealed[:12], sealed[12:], aad).decode("utf-8")
92def _encrypt_aes_gcm(value: str, signing_key: str) -> str:
93 """Encrypt under AES-256-GCM and return the versioned ``v2:gcm:`` string."""
94 sealed: Final = _seal_aes_gcm(value=value, signing_key=signing_key, aad=None)
95 return _V2_GCM_PREFIX + base64.urlsafe_b64encode(sealed).decode("utf-8")
98def _decrypt_aes_gcm(value: str, signing_key: str) -> str:
99 """Decrypt a versioned ``v2:gcm:`` string produced by :func:`_encrypt_aes_gcm`."""
100 sealed: Final = base64.urlsafe_b64decode(value[len(_V2_GCM_PREFIX) :])
101 return _open_aes_gcm(sealed=sealed, signing_key=signing_key, aad=None)
104def encrypt_bearer_token(value: str, prefix: str) -> str:
105 """AES-256-GCM as unpadded base64url behind ``prefix``, which is also the AAD so a token can't change kind."""
106 salt_key: Final = _get_salt_key()
107 if not isinstance(salt_key, str):
108 raise ValueError("Set LITELLM_SALT_KEY or a master key to mint bearer tokens") # noqa: TRY004 # missing config, not a bad argument type
109 sealed: Final = _seal_aes_gcm(value=value, signing_key=salt_key, aad=prefix.encode("utf-8"))
110 return prefix + base64.urlsafe_b64encode(sealed).decode("ascii").rstrip("=")
113def decrypt_bearer_token(token: str, prefix: str) -> str | None:
114 """None unless ``token`` came from :func:`encrypt_bearer_token` with the same ``prefix``."""
115 salt_key: Final = _get_salt_key()
116 if not isinstance(salt_key, str) or not token.startswith(prefix): 116 ↛ 118line 116 didn't jump to line 118 because the condition on line 116 was always true
117 return None
118 encoded: Final = token.removeprefix(prefix)
119 try:
120 sealed: Final = base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
121 return _open_aes_gcm(sealed=sealed, signing_key=salt_key, aad=prefix.encode("utf-8"))
122 except Exception: # noqa: BLE001 # base64 and AES-GCM each raise their own "not a token" type
123 return None
126def encrypt_value_helper(value: str, new_encryption_key: str | None = None):
127 signing_key: Final = new_encryption_key or _get_salt_key()
129 try:
130 if isinstance(value, str):
131 if _get_encryption_algorithm() == _ALGO_AES_GCM: 131 ↛ 134line 131 didn't jump to line 134 because the condition on line 131 was never true
132 # AES path: the v2:gcm: output is already a base64url string, so it
133 # is returned directly with no extra base64 wrapper.
134 return _encrypt_aes_gcm(value=value, signing_key=cast(str, signing_key))
136 encrypted_value = encrypt_value(value=value, signing_key=signing_key)
137 # Use urlsafe_b64encode for URL-safe base64 encoding (replaces + with - and / with _)
138 encrypted_value = base64.urlsafe_b64encode(encrypted_value).decode("utf-8")
140 return encrypted_value
142 verbose_proxy_logger.debug(
143 "Invalid value type passed to encrypt_value: %s for Value: %s\n Value must be a string", type(value), value
144 )
145 # if it's not a string - do not encrypt it and return the value
146 return value
147 except Exception as e:
148 raise e
151def _legacy_ciphertext_bytes(value: str) -> bytes:
152 # Try URL-safe base64 decoding first (new format)
153 # Fall back to standard base64 decoding for backwards compatibility (old format)
154 try:
155 return base64.urlsafe_b64decode(value)
156 except Exception:
157 return base64.b64decode(value)
160def _decrypt_with_signing_key(value: str, signing_key: str) -> str:
161 # Versioned AES-256-GCM values are detected before any base64 decode.
162 # The prefix is the algorithm tag the legacy nacl format never carried.
163 if value.startswith(_V2_GCM_PREFIX): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 return _decrypt_aes_gcm(value=value, signing_key=signing_key)
166 return decrypt_value(value=_legacy_ciphertext_bytes(value), signing_key=signing_key)
169def decrypt_if_encrypted_with(value: str, signing_key: str) -> str | None:
170 """None unless value is a ciphertext under signing_key."""
171 try:
172 # base64 decoding skips characters outside its alphabet, so "" and "*" decode to no bytes,
173 # which decrypt_value reads as an empty plaintext under any key.
174 decodes_to_nothing: Final = not value.startswith(_V2_GCM_PREFIX) and not _legacy_ciphertext_bytes(value)
175 return None if decodes_to_nothing else _decrypt_with_signing_key(value=value, signing_key=signing_key)
176 except Exception: # noqa: BLE001 # base64, nacl and AES-GCM each raise their own "not a ciphertext" type
177 return None
180def decrypt_value_helper(
181 value: str,
182 key: str, # this is just for debug purposes, showing the k,v pair that's invalid. not a signing key.
183 exception_type: Literal["debug", "error"] = "error",
184 return_original_value: bool = False,
185) -> str | None:
186 signing_key: Final = _get_salt_key()
188 try:
189 if isinstance(value, str):
190 return _decrypt_with_signing_key(value=value, signing_key=cast(str, signing_key))
192 # if it's not str - do not decrypt it, return the value
193 return value
194 except Exception as e:
195 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"
196 if exception_type == "debug":
197 verbose_proxy_logger.debug(error_message)
198 return value if return_original_value else None
200 verbose_proxy_logger.debug("Unable to decrypt value for key: %s, returning None", key)
201 if return_original_value: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 return value
203 else:
204 verbose_proxy_logger.exception(error_message)
205 # [Non-Blocking Exception. - this should not block decrypting other values]
206 return None
209def encrypt_value(value: str, signing_key: str):
210 import hashlib
212 import nacl.secret
213 import nacl.utils
215 # get 32 byte master key #
216 hash_object: Final = hashlib.sha256(signing_key.encode())
217 hash_bytes: Final = hash_object.digest()
219 # initialize secret box #
220 box: Final = nacl.secret.SecretBox(hash_bytes)
222 # encode message #
223 value_bytes: Final = value.encode("utf-8")
225 encrypted: Final = box.encrypt(value_bytes)
227 return encrypted
230def decrypt_value(value: bytes, signing_key: str) -> str:
231 import hashlib
233 import nacl.secret
234 import nacl.utils
236 # get 32 byte master key #
237 hash_object: Final = hashlib.sha256(signing_key.encode())
238 hash_bytes: Final = hash_object.digest()
240 # initialize secret box #
241 box: Final = nacl.secret.SecretBox(hash_bytes)
243 # Convert the bytes object to a string
244 try:
245 if len(value) == 0:
246 return ""
248 plaintext = box.decrypt(value)
249 plaintext = plaintext.decode("utf-8")
250 return plaintext
251 except Exception as e:
252 raise e
255class SecretMapDecodeError(RuntimeError):
256 pass
259_SECRET_MAP: Final = TypeAdapter(Mapping[str, str])
260_STORED_SECRET_MAP: Final = TypeAdapter(Mapping[str, str] | str)
261_SECRET_STRING: Final = TypeAdapter(str)
264def encrypt_secret_map(value: Mapping[str, str], new_encryption_key: str | None = None) -> str:
265 if not value:
266 return "{}"
267 ciphertext: Final = _SECRET_STRING.validate_python(
268 encrypt_value_helper(_SECRET_MAP.dump_json(value).decode(), new_encryption_key=new_encryption_key), strict=True
269 )
270 return _SECRET_STRING.dump_json(ciphertext).decode()
273def decode_secret_map(value: object, *, key: str) -> Mapping[str, str] | None:
274 if value is None:
275 return None
276 try:
277 stored: Final = (
278 _STORED_SECRET_MAP.validate_json(value, strict=True)
279 if isinstance(value, str) and value.lstrip().startswith(("{", '"'))
280 else _STORED_SECRET_MAP.validate_python(value, strict=True)
281 )
282 if not isinstance(stored, str):
283 return stored
284 decrypted: Final = decrypt_value_helper(
285 value=stored, key=key, exception_type="debug", return_original_value=False
286 )
287 return _SECRET_MAP.validate_json(decrypted, strict=True)
288 except ValidationError:
289 raise SecretMapDecodeError(f"Cannot decode encrypted MCP {key}; check LITELLM_SALT_KEY") from None