From b4c0a753953659f562ad946d88eeae555e0fd952 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 06:31:06 -0800 Subject: [PATCH 1/6] feat(proxy): hash UI passwords with PBKDF2 in FIPS mode A FIPS OpenSSL refuses scrypt, so every password write fails there and nobody can sign in with a password. When OpenSSL enforces FIPS, passwords now use PBKDF2-HMAC-SHA256 at 600k iterations with a salt from OpenSSL's DRBG, stored as "pbkdf2:" plus base64 of salt and key. Any other row is refused with one error log and left as it is, and the boot migration does nothing, because FIPS mode is for new deployments. PBKDF2 at 600k takes about five times as long as scrypt, so in FIPS mode the five request paths run it on one thread instead of the event loop. A single thread keeps a login burst from using up a CPU quota, which is what stalls the loop with asyncio.to_thread. A login that queued for that thread asks the throttle again before it answers, so a burst of guesses can't get past a block that the failures ahead of it just set. The breached password check is off in FIPS mode, because it runs the password through SHA-1. A standard OpenSSL reads False at every new branch, so standard mode keeps scrypt, inline, on the same rows. Resolves LIT-8425 --- litellm/proxy/auth/login_throttle.py | 4 + litellm/proxy/auth/login_utils.py | 9 +- litellm/proxy/auth/password_policy.py | 3 + .../internal_user_endpoints.py | 4 +- .../password_endpoints.py | 6 +- litellm/proxy/proxy_server.py | 3 +- litellm/proxy/utils.py | 35 ++++- tests/unit/proxy/auth/test_login_utils.py | 123 +++++++++++++++++ tests/unit/proxy/auth/test_onboarding.py | 9 +- tests/unit/proxy/auth/test_password_policy.py | 8 ++ tests/unit/proxy/conftest.py | 29 ++++ .../test_internal_user_endpoints.py | 13 ++ .../test_password_endpoints.py | 25 ++++ .../prisma_and_spend/test_password_helpers.py | 124 +++++++++++++++++- 14 files changed, 383 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/auth/login_throttle.py b/litellm/proxy/auth/login_throttle.py index 0536e293f13..27f049a950e 100644 --- a/litellm/proxy/auth/login_throttle.py +++ b/litellm/proxy/auth/login_throttle.py @@ -431,6 +431,10 @@ class LoginAttempt: throttle: LoginThrottle username: str + async def refuse_if_blocked_while_waiting(self) -> None: + """Refuses a login that waited in line while failures ahead of it blocked this key.""" + await self.throttle.attempt(self.username) + async def succeeded(self) -> None: if not self.throttle.enabled: return diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 9783f44b41f..6bbb2668905 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -31,6 +31,7 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured from litellm.proxy.auth.login_throttle import LoginAttempt, LoginThrottle from litellm.proxy.auth.password_policy import is_breach_check_enabled, is_password_breached +from litellm.proxy.common_utils.fips import openssl_enforces_fips from litellm.proxy.management_endpoints.internal_user_endpoints import user_update from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, @@ -42,6 +43,7 @@ from litellm.proxy.utils import ( PrismaClient, get_server_root_path, hash_password, + run_off_loop_if_fips, verify_password, ) from litellm.repositories.user_repository import UserRepository @@ -107,7 +109,7 @@ async def screen_login_password_for_breach( async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None: """Rehash legacy password (SHA256) to scrypt on successful login.""" - if stored.startswith("scrypt:"): + if stored.startswith(("scrypt:", "pbkdf2:")): return from litellm.proxy.proxy_server import prisma_client @@ -411,7 +413,10 @@ async def _sign_in( code=401, ) - if verify_password(password, _password): + password_matches: Final = await run_off_loop_if_fips(verify_password, password, _password) + if openssl_enforces_fips(): + await attempt.refuse_if_blocked_while_waiting() + if password_matches: await _rehash_password_if_needed(_user_row.user_id, password, _password) breached_now: Final = prisma_client is not None and await screen_login_password_for_breach( user_id=_user_row.user_id, diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py index d810ba4238b..21c93a42c76 100644 --- a/litellm/proxy/auth/password_policy.py +++ b/litellm/proxy/auth/password_policy.py @@ -24,6 +24,7 @@ from litellm.constants import HIBP_RANGE_API_BASE from litellm.litellm_core_utils.offline_mode import is_offline_mode from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client from litellm.proxy._types import ProxyErrorTypes, ProxyException +from litellm.proxy.common_utils.fips import openssl_enforces_fips from litellm.types.llms.custom_http import httpxSpecialProvider HIBP_TIMEOUT_SECONDS: Final = 5.0 @@ -144,6 +145,8 @@ async def _is_password_breached(password: str, client: AsyncHTTPHandler) -> bool def is_breach_check_enabled(general_settings: Mapping[str, object]) -> bool: + if openssl_enforces_fips(): + return False return not is_offline_mode() and general_settings.get("password_policy_check_breached_passwords", True) is not False diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 93a429a2fcc..61ab7c1ab22 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -85,7 +85,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( # noqa: ) from litellm.proxy.management_helpers.team_roster_sync import user_tx_db from litellm.proxy.management_helpers.utils import management_endpoint_wrapper -from litellm.proxy.utils import handle_exception_on_proxy, hash_password +from litellm.proxy.utils import handle_exception_on_proxy, hash_password, run_off_loop_if_fips from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.prisma_protocols import TableActions from litellm.repositories.table_repositories import ( @@ -207,7 +207,7 @@ async def _hash_password_in_dict( if not password_prevalidated: validate_password_policy(data["password"], general_settings) await validate_password_not_breached(data["password"], general_settings, hibp_client) - data["password"] = hash_password(data["password"]) + data["password"] = await run_off_loop_if_fips(hash_password, data["password"]) data["password_reset_required"] = True data["last_breach_check_at"] = None diff --git a/litellm/proxy/management_endpoints/password_endpoints.py b/litellm/proxy/management_endpoints/password_endpoints.py index 16f3e7dfcfb..21172e30639 100644 --- a/litellm/proxy/management_endpoints/password_endpoints.py +++ b/litellm/proxy/management_endpoints/password_endpoints.py @@ -33,7 +33,7 @@ from litellm.proxy.auth.password_policy import ( from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.session_endpoints import revoke_ui_session_keys from litellm.proxy.management_helpers.audit_logs import create_object_audit_log -from litellm.proxy.utils import hash_password, verify_password +from litellm.proxy.utils import hash_password, run_off_loop_if_fips, verify_password from litellm.repositories.prisma_protocols import TableActions from litellm.repositories.user_repository import UserRepository @@ -129,7 +129,7 @@ async def change_password( ), ) - if not verify_password(data.current_password, stored_password): + if not await run_off_loop_if_fips(verify_password, data.current_password, stored_password): raise HTTPException(status_code=400, detail=_error_detail("Current password is incorrect.")) if data.new_password == data.current_password: @@ -142,7 +142,7 @@ async def change_password( await validate_password_not_breached(data.new_password, general_settings, hibp_client) password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = { - "password": hash_password(data.new_password), + "password": await run_off_loop_if_fips(hash_password, data.new_password), "password_reset_required": False, "last_breach_check_at": None, } diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c1af5d6f980..2e4250e36a2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1031,6 +1031,7 @@ from litellm.proxy.utils import ( # noqa: F401, RUF100 # legacy module exports migrate_passwords_to_scrypt_async, model_dump_with_preserved_fields, prefetch_config_params, + run_off_loop_if_fips, update_spend, ) from litellm.proxy.video_endpoints.endpoints import router as video_router @@ -19863,7 +19864,7 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request): validate_password_policy(data.password, general_settings) await validate_password_not_breached(data.password, general_settings) - hashed_pw: Final = hash_password(data.password) + hashed_pw: Final = await run_off_loop_if_fips(hash_password, data.password) current_time = litellm.utils.get_utc_datetime() tx_manager: Final[_InvitationAcceptTxManager] = prisma_client.db.tx() async with tx_manager as tx: diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 3102674376a..ba50bc1e971 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -6,6 +6,7 @@ import inspect import json import math import os +import re import smtplib import ssl import sys @@ -24,6 +25,7 @@ from collections.abc import ( Mapping, Sequence, ) +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from datetime import date, datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart @@ -85,7 +87,9 @@ from litellm.proxy._types import ( SpendLogsMetadata, SpendLogsPayload, ) +from litellm.proxy.auth.password_policy import MIN_ALLOWED_LENGTH from litellm.proxy.bug_report_config import build_proxy_bug_report +from litellm.proxy.common_utils.fips import openssl_enforces_fips from litellm.proxy.common_utils.openai_error_payload import ( attribute_of, error_status_code, @@ -8400,22 +8404,37 @@ def hash_token(token: str): def hash_password(password: str) -> str: - """Hash a password using scrypt with a random salt.""" + """Hash a password using scrypt with a random salt, or PBKDF2 in FIPS mode.""" import base64 import hashlib import os + if openssl_enforces_fips(): + fips_salt: Final = ssl.RAND_bytes(16) + fips_dk: Final = hashlib.pbkdf2_hmac("sha256", password.encode(), fips_salt, iterations=600_000, dklen=32) + return "pbkdf2:" + base64.b64encode(fips_salt + fips_dk).decode() salt: Final = os.urandom(16) dk: Final = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32) return "scrypt:" + base64.b64encode(salt + dk).decode() def verify_password(password: str, stored: str) -> bool: - """Verify a password against a stored hash. Supports scrypt and SHA256.""" + """Verify a password against a stored hash. Supports scrypt and SHA256, or only PBKDF2 in FIPS mode.""" import base64 import hashlib import secrets + if openssl_enforces_fips(): + if re.fullmatch(r"pbkdf2:[A-Za-z0-9+/]{64}", stored) is None: + verbose_proxy_logger.error("FIPS mode refuses a non-PBKDF2 password row. Reset it with /user/update.") + return False + if len(password) < MIN_ALLOWED_LENGTH: + return False + fips_row: Final = base64.b64decode(stored[7:]) + fips_dk: Final = hashlib.pbkdf2_hmac( + "sha256", password.encode(errors="surrogatepass"), fips_row[:16], iterations=600_000, dklen=32 + ) + return secrets.compare_digest(fips_row[16:], fips_dk) if stored.startswith("scrypt:"): try: raw: Final = base64.b64decode(stored[7:]) @@ -8430,12 +8449,24 @@ def verify_password(password: str, stored: str) -> bool: return False +_FIPS_KDF_POOL: Final = ThreadPoolExecutor(max_workers=1, thread_name_prefix="fips-password-kdf") + + +async def run_off_loop_if_fips(kdf: Callable[..., _T], *args: str) -> _T: + """Run a password KDF inline, or on the single KDF thread in FIPS mode.""" + if not openssl_enforces_fips(): + return kdf(*args) + return await asyncio.get_running_loop().run_in_executor(_FIPS_KDF_POOL, kdf, *args) + + async def migrate_passwords_to_scrypt_async(prisma_client) -> str: """ Migrate plaintext passwords in the DB to scrypt. SHA256 passwords are left alone (they migrate on next login via the SHA256 fallback). Skips quickly if no plaintext passwords exist. """ + if openssl_enforces_fips(): + return "Skipped, FIPS mode has no password migration" all_with_pw: Final = await UserRepository(prisma_client).table.find_many( where={"password": {"not": None}}, ) diff --git a/tests/unit/proxy/auth/test_login_utils.py b/tests/unit/proxy/auth/test_login_utils.py index 5148e38aa61..0a89f767eb4 100644 --- a/tests/unit/proxy/auth/test_login_utils.py +++ b/tests/unit/proxy/auth/test_login_utils.py @@ -2365,3 +2365,126 @@ class TestScreenLoginPasswordForBreach: ) is True ) + + +_PBKDF2_PASSWORD: Final = "Seed-Lamp-4-River" +_PBKDF2_ROW: Final = "pbkdf2:AAECAwQFBgcICQoLDA0OD+uxhNqRo/3ZFLEaKARqFBT+N3Kqek/SGM/5AvocSN0T" +_SCRYPT_ROW: Final = "scrypt:AAECAwQFBgcICQoLDA0OD7hIwlM/k8ytnffjFDt6AuO+BvqtmZvDkWXJP+A3bf0N" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "stored, schemes_written", + [ + (_SCRYPT_ROW, []), + (_PBKDF2_ROW, []), + (hashlib.sha256(_PBKDF2_PASSWORD.encode()).hexdigest(), ["scrypt"]), + ], + ids=["scrypt", "pbkdf2", "sha256"], +) +async def test_login_rehash_rewrites_only_a_legacy_row(stored: str, schemes_written: list[str]) -> None: + from litellm.proxy.auth.login_utils import _rehash_password_if_needed + + prisma = MagicMock() + prisma.db.litellm_usertable.update = AsyncMock() + with patch( # test-quality-ok: the rehash reads proxy_server's prisma_client global; no injection seam + "litellm.proxy.proxy_server.prisma_client", prisma + ): + await _rehash_password_if_needed("u-1", _PBKDF2_PASSWORD, stored) + + updates = prisma.db.litellm_usertable.update.await_args_list + assert [update.kwargs["data"]["password"].split(":")[0] for update in updates] == schemes_written + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_login_runs_one_pbkdf2_off_the_event_loop_and_writes_no_row( + monkeypatch: pytest.MonkeyPatch, pbkdf2_on_event_loop: list[bool] +) -> None: + from litellm.proxy.auth.login_utils import authenticate_user + + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") + repository = _known_user("user@corp.com") + repository.return_value.table.find_first.return_value.password = _PBKDF2_ROW + repository.return_value.table.update = AsyncMock() + with ( + patch( # test-quality-ok: the user lookup and rehash write are the database boundary + "litellm.proxy.auth.login_utils.UserRepository", repository + ), + patch( # test-quality-ok: the rehash reads proxy_server's prisma_client global; no injection seam + "litellm.proxy.proxy_server.prisma_client", MagicMock() + ), + patch( # test-quality-ok: success mints a UI key; faked so no DB is needed + "litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"}) + ), + ): + result = await authenticate_user( + username="user@corp.com", + password=_PBKDF2_PASSWORD, + master_key="sk-master", + prisma_client=MagicMock(), + throttle=_unlimited_throttle(), + general_settings=_POLICY_NO_BREACH_CHECK, + ) + + assert result.key == "sk-ui" + assert pbkdf2_on_event_loop == [False] + repository.return_value.table.update.assert_not_awaited() + + +async def _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch: pytest.MonkeyPatch, row: str): + from litellm.proxy.auth.login_utils import authenticate_user + + throttle = _throttle(user_limit=1) + repository = _known_user() + user = repository.return_value.table.find_first.return_value + user.password = row + + async def block_the_user_then_return_the_row(**_: object) -> MagicMock: + await throttle.record_failure("known@example.com") + await throttle.record_failure("known@example.com") + return user + + repository.return_value.table.find_first = AsyncMock(side_effect=block_the_user_then_return_the_row) + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") + with ( + patch( # test-quality-ok: the user lookup is the database boundary + "litellm.proxy.auth.login_utils.UserRepository", repository + ), + patch( # test-quality-ok: the rehash reads proxy_server's prisma_client global; no injection seam + "litellm.proxy.proxy_server.prisma_client", MagicMock() + ), + patch( # test-quality-ok: success mints a UI key; faked so no DB is needed + "litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"}) + ), + ): + return await authenticate_user( + username="known@example.com", + password=_PBKDF2_PASSWORD, + master_key="sk-master", + prisma_client=MagicMock(), + throttle=throttle, + general_settings=_POLICY_NO_BREACH_CHECK, + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_login_hides_the_right_password_when_its_user_got_blocked_while_it_waited( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.proxy._types import ProxyException + + with pytest.raises(ProxyException) as blocked: + await _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch, _PBKDF2_ROW) + + assert blocked.value.code == "429" + + +@pytest.mark.asyncio +async def test_standard_login_past_the_throttle_keeps_its_answer_when_its_user_gets_blocked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + result = await _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch, _SCRYPT_ROW) + + assert result.key == "sk-ui" diff --git a/tests/unit/proxy/auth/test_onboarding.py b/tests/unit/proxy/auth/test_onboarding.py index 46a48c21353..6f907fab181 100644 --- a/tests/unit/proxy/auth/test_onboarding.py +++ b/tests/unit/proxy/auth/test_onboarding.py @@ -462,7 +462,7 @@ async def test_claim_token_sets_accepted_at_after_password_written(): prisma.db.litellm_usertable.update.assert_called_once() call_kwargs = prisma.db.litellm_usertable.update.call_args assert call_kwargs.kwargs["where"] == {"user_id": "user-123"} - assert "password" in call_kwargs.kwargs["data"] + assert call_kwargs.kwargs["data"]["password"].startswith(("scrypt:", "pbkdf2:")) # A freshly claimed, policy-screened password lifts any pending forced # reset and re-arms the login-time breach screen. assert call_kwargs.kwargs["data"]["password_reset_required"] is False @@ -477,6 +477,13 @@ async def test_claim_token_sets_accepted_at_after_password_written(): assert outer_claims["key"] == "sk-generated-key" +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_claim_hashes_off_the_event_loop(pbkdf2_on_event_loop: list[bool]) -> None: + await test_claim_token_sets_accepted_at_after_password_written() + assert pbkdf2_on_event_loop == [False] + + @pytest.mark.asyncio async def test_claim_token_revokes_existing_ui_sessions(): """A claimed invite/reset link changes the password; any UI session minted diff --git a/tests/unit/proxy/auth/test_password_policy.py b/tests/unit/proxy/auth/test_password_policy.py index 0ad1083ebe0..561422c380e 100644 --- a/tests/unit/proxy/auth/test_password_policy.py +++ b/tests/unit/proxy/auth/test_password_policy.py @@ -20,6 +20,7 @@ from litellm.proxy.auth.password_policy import ( MIN_ALLOWED_LENGTH, PasswordPolicy, get_password_policy, + is_password_breached, validate_password_not_breached, validate_password_policy, validate_passwords_bulk, @@ -355,3 +356,10 @@ async def test_validate_passwords_bulk_mixed_verdicts(): async def test_validate_passwords_bulk_empty_batch_makes_no_lookups(): verdicts = await validate_passwords_bulk((), {}, client=_client_never_called()) assert verdicts == {} + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_mode_skips_the_breach_check() -> None: + client_says_breached = _client_returning(f"{_sha1_upper(STRONG_PASSWORD)[5:]}:1387") + assert await is_password_breached(STRONG_PASSWORD, {}, client_says_breached) is False diff --git a/tests/unit/proxy/conftest.py b/tests/unit/proxy/conftest.py index 298d32def7d..7f89bb2e459 100644 --- a/tests/unit/proxy/conftest.py +++ b/tests/unit/proxy/conftest.py @@ -1,7 +1,9 @@ # conftest.py +import _hashlib import asyncio import copy +import hashlib import inspect import logging import os @@ -514,3 +516,30 @@ def postgres_span_names() -> Iterator[Callable[[], Awaitable[tuple[str, ...]]]]: with patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(service_logging_obj=service_logging)): yield rendered + + +@pytest.fixture +def fips_openssl(monkeypatch: pytest.MonkeyPatch) -> None: + real_new: Final = _hashlib.new + + def new(name: str, data: bytes = b"", *, usedforsecurity: bool = True) -> _hashlib.HASH: + if name == "md5": + raise _hashlib.UnsupportedDigestmodError("unsupported hash type") + return real_new(name, data, usedforsecurity=usedforsecurity) + + monkeypatch.setattr(_hashlib, "get_fips_mode", lambda: 1) + monkeypatch.setattr(_hashlib, "new", new) + + +@pytest.fixture +def pbkdf2_on_event_loop(monkeypatch: pytest.MonkeyPatch) -> list[bool]: + """One entry per PBKDF2 call: whether it ran on the event loop's thread.""" + real_pbkdf2: Final = hashlib.pbkdf2_hmac + on_event_loop: Final[list[bool]] = [] + + def recording_pbkdf2(hash_name: str, password: bytes, salt: bytes, iterations: int, dklen: int) -> bytes: + on_event_loop.append(asyncio._get_running_loop() is not None) + return real_pbkdf2(hash_name, password, salt, iterations, dklen) + + monkeypatch.setattr(hashlib, "pbkdf2_hmac", recording_pbkdf2) + return on_event_loop diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index b96a5b606eb..b1d4e89bde6 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -5483,3 +5483,16 @@ async def test_user_update_reports_a_bad_budget_duration_as_a_request_error_not_ assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error") assert "Invalid budget_duration" in exc_info.value.message assert "Authentication Error" not in exc_info.value.message + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_user_update_hashes_off_the_event_loop(pbkdf2_on_event_loop: list[bool]) -> None: + from litellm.proxy.management_endpoints.internal_user_endpoints import _hash_password_in_dict + + data: Final = {"password": "Str0ng!Passw0rd"} + + await _hash_password_in_dict(data, {"password_policy_check_breached_passwords": False}) + + assert pbkdf2_on_event_loop == [False] + assert data["password"].startswith("pbkdf2:") diff --git a/tests/unit/proxy/management_endpoints/test_password_endpoints.py b/tests/unit/proxy/management_endpoints/test_password_endpoints.py index adff4eda47c..fd66419d28e 100644 --- a/tests/unit/proxy/management_endpoints/test_password_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_password_endpoints.py @@ -490,3 +490,28 @@ async def test_change_password_requires_db(): ) assert exc_info.value.status_code == 500 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_password_change_runs_both_pbkdf2_off_the_event_loop(pbkdf2_on_event_loop: list[bool]) -> None: + from litellm.proxy._types import ChangePasswordRequest + + prisma = _make_prisma(_make_user_row("pbkdf2:AAECAwQFBgcICQoLDA0OD+uxhNqRo/3ZFLEaKARqFBT+N3Kqek/SGM/5AvocSN0T")) + + with ( + patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam + "litellm.proxy.proxy_server.prisma_client", prisma + ), + patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam + "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK + ), + ): + await change_password( + data=ChangePasswordRequest(current_password="Seed-Lamp-4-River", new_password=NEW_PASSWORD), + user_api_key_dict=_caller(), + hibp_client=_hibp_client_never_called(), + ) + + assert pbkdf2_on_event_loop == [False, False] + assert prisma.db.litellm_usertable.update.call_args.kwargs["data"]["password"].startswith("pbkdf2:") diff --git a/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py b/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py index f241cd00cf5..f862b3413c3 100644 --- a/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py +++ b/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py @@ -4,6 +4,7 @@ Symbols pinned here: - ``hash_token`` - ``hash_password`` - ``verify_password`` + - ``run_off_loop_if_fips`` - ``migrate_passwords_to_scrypt_async`` - ``_hash_token_if_needed`` - ``PrismaClient._is_sha256_hex`` (a nested helper inside @@ -13,9 +14,12 @@ Symbols pinned here: from __future__ import annotations +import asyncio import hashlib +import ssl +import threading from types import SimpleNamespace -from typing import List +from typing import Final, List from unittest.mock import AsyncMock, MagicMock import pytest @@ -25,9 +29,17 @@ from litellm.proxy.utils import ( hash_password, hash_token, migrate_passwords_to_scrypt_async, + run_off_loop_if_fips, verify_password, ) +PASSWORD: Final = "Seed-Lamp-4-River" +PBKDF2_ROW: Final = "pbkdf2:AAECAwQFBgcICQoLDA0OD+uxhNqRo/3ZFLEaKARqFBT+N3Kqek/SGM/5AvocSN0T" +EIGHT_CHARACTER_PBKDF2_ROW: Final = "pbkdf2:AAECAwQFBgcICQoLDA0ODxhpezknSiGV0cY8wgaSXYf/xzxcPw49uGdOt/TBvPPH" +SEVEN_CHARACTER_PBKDF2_ROW: Final = "pbkdf2:AAECAwQFBgcICQoLDA0ODxFIwl/XKIAeyWEnfqz8H9vNQX4uo7/u1HiJhB7Ro+N7" +SCRYPT_ROW: Final = "scrypt:AAECAwQFBgcICQoLDA0OD7hIwlM/k8ytnffjFDt6AuO+BvqtmZvDkWXJP+A3bf0N" +SHA256_ROW: Final = hashlib.sha256(PASSWORD.encode()).hexdigest() + def test_hash_token_returns_sha256_hex_of_input() -> None: token = "sk-abcDEF12345" @@ -217,3 +229,113 @@ async def test_migrate_passwords_raises_on_db_failure() -> None: pc.db.litellm_usertable.find_many = AsyncMock(side_effect=RuntimeError("db unavailable")) with pytest.raises(RuntimeError, match="db unavailable"): await migrate_passwords_to_scrypt_async(pc) + + +@pytest.mark.usefixtures("fips_openssl") +def test_fips_hash_is_pbkdf2_sha256_at_600k_salted_by_openssl(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ssl, "RAND_bytes", lambda size: bytes(range(size))) + assert hash_password(PASSWORD) == PBKDF2_ROW + + +@pytest.mark.usefixtures("fips_openssl") +def test_fips_hash_salts_every_row_and_verifies() -> None: + first, second = hash_password(PASSWORD), hash_password(PASSWORD) + assert first != second + assert verify_password(PASSWORD, first) + + +@pytest.mark.usefixtures("fips_openssl") +def test_fips_verify_needs_the_right_password_of_at_least_eight_characters() -> None: + outcome = { + "right": verify_password(PASSWORD, PBKDF2_ROW), + "wrong": verify_password("Seed-Lamp-4-Rivet", PBKDF2_ROW), + "lone_surrogate": verify_password("\ud800" + PASSWORD, PBKDF2_ROW), + "eight_characters": verify_password("Seed-Lam", EIGHT_CHARACTER_PBKDF2_ROW), + "seven_characters": verify_password("Seed-La", SEVEN_CHARACTER_PBKDF2_ROW), + } + assert outcome == { + "right": True, + "wrong": False, + "lone_surrogate": False, + "eight_characters": True, + "seven_characters": False, + } + + +@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.parametrize( + "row", + [ + SCRYPT_ROW, + SHA256_ROW, + PASSWORD, + "pbkdf2:", + PBKDF2_ROW[:-1], + PBKDF2_ROW + "A", + PBKDF2_ROW + "\n", + "pbkdf2:" + "@" * 64, + "pbkdf2:sha256:1000:AAECAwQFBgcICQoLDA0ODw==:67GE2pGj/dkUsRooBGoUFP43cqp6T9IYz/kC+hxI3RM=", + ], + ids=[ + "scrypt", + "sha256", + "plaintext", + "prefix-only", + "short", + "long", + "newline", + "not-base64", + "stored-iterations", + ], +) +def test_fips_refuses_any_other_row_with_one_error_that_hides_it(caplog: pytest.LogCaptureFixture, row: str) -> None: + with caplog.at_level("ERROR", logger="LiteLLM Proxy"): + assert verify_password(PASSWORD, row) is False + assert [record.levelname for record in caplog.records] == ["ERROR"] + assert row not in caplog.text + + +@pytest.mark.parametrize( + "row, verifies", + [(SCRYPT_ROW, True), (SHA256_ROW, True), (PBKDF2_ROW, False)], + ids=["scrypt", "sha256", "pbkdf2"], +) +def test_standard_mode_verifies_the_rows_it_always_did(row: str, verifies: bool) -> None: + assert verify_password(PASSWORD, row) is verifies + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_boot_migration_writes_no_row() -> None: + pc = MagicMock() + pc.db.litellm_usertable.find_many = AsyncMock( + return_value=[_make_user("plaintext-user", PASSWORD), _make_user("fips-user", PBKDF2_ROW)] + ) + pc.db.litellm_usertable.update = AsyncMock() + + await migrate_passwords_to_scrypt_async(pc) + + pc.db.litellm_usertable.update.assert_not_awaited() + + +def _on_event_loop() -> bool: + return asyncio._get_running_loop() is not None + + +@pytest.mark.asyncio +async def test_password_kdf_runs_on_the_event_loop_in_standard_mode() -> None: + assert await run_off_loop_if_fips(_on_event_loop) is True + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_password_kdfs_leave_the_event_loop_and_never_overlap_in_fips_mode() -> None: + second_started: Final = threading.Event() + + def first() -> tuple[bool, bool]: + return _on_event_loop(), second_started.wait(timeout=0.2) + + (first_on_event_loop, overlapped), _ = await asyncio.gather( + run_off_loop_if_fips(first), run_off_loop_if_fips(second_started.set) + ) + assert (first_on_event_loop, overlapped) == (False, False) From fa160e060393437ce7c693291f36acb4466d15eb Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 06:58:03 -0800 Subject: [PATCH 2/6] fix(proxy): take FIPS logins one at a time and recheck the throttle first A login that waited for the KDF thread asked the throttle again only after deriving, so a burst for one user kept that thread busy long after the user was blocked. Logins now wait for their turn on a lock and ask the throttle when it comes, so a blocked login costs no PBKDF2 and only one login at a time holds a place in the KDF queue. Password changes and invite claims no longer wait behind a whole burst of logins. --- litellm/proxy/auth/login_utils.py | 18 ++++++++--- tests/unit/proxy/auth/test_login_utils.py | 37 ++++++++++++++++++++++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 6bbb2668905..5ff93eea7f2 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -5,6 +5,7 @@ This module contains the core login logic that can be reused across different login endpoints (e.g., /login and /v2/login). """ +import asyncio import os import secrets from collections.abc import Mapping @@ -107,6 +108,18 @@ async def screen_login_password_for_breach( return breached +_ONE_FIPS_LOGIN_AT_A_TIME: Final = asyncio.Lock() + + +async def _password_matches(password: str, stored: str, attempt: LoginAttempt) -> bool: + """Checks inline, or in FIPS mode one login at a time, refusing a login whose user got blocked while it waited.""" + if not openssl_enforces_fips(): + return verify_password(password, stored) + async with _ONE_FIPS_LOGIN_AT_A_TIME: + await attempt.refuse_if_blocked_while_waiting() + return await run_off_loop_if_fips(verify_password, password, stored) + + async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None: """Rehash legacy password (SHA256) to scrypt on successful login.""" if stored.startswith(("scrypt:", "pbkdf2:")): @@ -413,10 +426,7 @@ async def _sign_in( code=401, ) - password_matches: Final = await run_off_loop_if_fips(verify_password, password, _password) - if openssl_enforces_fips(): - await attempt.refuse_if_blocked_while_waiting() - if password_matches: + if await _password_matches(password, _password, attempt): await _rehash_password_if_needed(_user_row.user_id, password, _password) breached_now: Final = prisma_client is not None and await screen_login_password_for_breach( user_id=_user_row.user_id, diff --git a/tests/unit/proxy/auth/test_login_utils.py b/tests/unit/proxy/auth/test_login_utils.py index 0a89f767eb4..ccfffaf3999 100644 --- a/tests/unit/proxy/auth/test_login_utils.py +++ b/tests/unit/proxy/auth/test_login_utils.py @@ -5,6 +5,7 @@ This module tests the refactored login logic that was moved from proxy_server.py to login_utils.py for better reusability. """ +import asyncio import hashlib import os from collections.abc import Mapping @@ -2471,7 +2472,7 @@ async def _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch: pyte @pytest.mark.asyncio @pytest.mark.usefixtures("fips_openssl") async def test_fips_login_hides_the_right_password_when_its_user_got_blocked_while_it_waited( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, pbkdf2_on_event_loop: list[bool] ) -> None: from litellm.proxy._types import ProxyException @@ -2479,6 +2480,40 @@ async def test_fips_login_hides_the_right_password_when_its_user_got_blocked_whi await _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch, _PBKDF2_ROW) assert blocked.value.code == "429" + assert pbkdf2_on_event_loop == [] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_burst_of_wrong_passwords_stops_deriving_once_its_user_is_blocked( + monkeypatch: pytest.MonkeyPatch, pbkdf2_on_event_loop: list[bool] +) -> None: + from litellm.proxy.auth.login_utils import authenticate_user + + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") + repository = _known_user() + repository.return_value.table.find_first.return_value.password = _PBKDF2_ROW + throttle = _throttle(user_limit=1) + with patch( # test-quality-ok: the user lookup is the database boundary + "litellm.proxy.auth.login_utils.UserRepository", repository + ): + refusals = await asyncio.gather( + *( + authenticate_user( + username="known@example.com", + password="Wrong-Lamp-4-River", + master_key="sk-master", + prisma_client=MagicMock(), + throttle=throttle, + general_settings=_POLICY_NO_BREACH_CHECK, + ) + for _ in range(6) + ), + return_exceptions=True, + ) + + assert [refusal.code for refusal in refusals] == ["401", "401", "429", "429", "429", "429"] + assert pbkdf2_on_event_loop == [False, False] @pytest.mark.asyncio From e331a9402396d682743512936aa249b609c90b81 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 07:16:55 -0800 Subject: [PATCH 3/6] fix(proxy): keep PBKDF2 rows on a standard boot and warn when FIPS skips the breach check A standard boot treated a pbkdf2: row as plaintext and hashed its text, so the real password stopped working and the old row text signed in. The migration now leaves a row of that exact shape alone. Any other pbkdf2: text still migrates. FIPS mode now logs a warning when it skips a breach check that is turned on. The one-thread test no longer depends on timing, and the new test variables are annotated Final. --- litellm/proxy/auth/password_policy.py | 8 +++- litellm/proxy/utils.py | 11 ++++- tests/unit/proxy/auth/test_login_utils.py | 22 +++++----- tests/unit/proxy/auth/test_password_policy.py | 18 ++++++-- .../test_password_endpoints.py | 4 +- .../prisma_and_spend/test_password_helpers.py | 41 +++++++++++++------ 6 files changed, 72 insertions(+), 32 deletions(-) diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py index 21c93a42c76..a40fa109e82 100644 --- a/litellm/proxy/auth/password_policy.py +++ b/litellm/proxy/auth/password_policy.py @@ -145,9 +145,13 @@ async def _is_password_breached(password: str, client: AsyncHTTPHandler) -> bool def is_breach_check_enabled(general_settings: Mapping[str, object]) -> bool: - if openssl_enforces_fips(): + enabled: Final = ( + not is_offline_mode() and general_settings.get("password_policy_check_breached_passwords", True) is not False + ) + if enabled and openssl_enforces_fips(): + verbose_proxy_logger.warning("FIPS mode skips the breached password check because its lookup needs SHA-1.") return False - return not is_offline_mode() and general_settings.get("password_policy_check_breached_passwords", True) is not False + return enabled async def is_password_breached( diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index ba50bc1e971..03b82658ec2 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -8403,6 +8403,10 @@ def hash_token(token: str): return hashed_token +def _is_pbkdf2_row(stored: str) -> bool: + return re.fullmatch(r"pbkdf2:[A-Za-z0-9+/]{64}", stored) is not None + + def hash_password(password: str) -> str: """Hash a password using scrypt with a random salt, or PBKDF2 in FIPS mode.""" import base64 @@ -8425,7 +8429,7 @@ def verify_password(password: str, stored: str) -> bool: import secrets if openssl_enforces_fips(): - if re.fullmatch(r"pbkdf2:[A-Za-z0-9+/]{64}", stored) is None: + if not _is_pbkdf2_row(stored): verbose_proxy_logger.error("FIPS mode refuses a non-PBKDF2 password row. Reset it with /user/update.") return False if len(password) < MIN_ALLOWED_LENGTH: @@ -8477,7 +8481,10 @@ async def migrate_passwords_to_scrypt_async(prisma_client) -> str: plaintext_users: Final = [ (u.user_id, u.password) for u in all_with_pw - if u.password and not u.password.startswith("scrypt:") and not _is_sha256_hex(u.password) + if u.password + and not u.password.startswith("scrypt:") + and not _is_sha256_hex(u.password) + and not _is_pbkdf2_row(u.password) ] if not plaintext_users: return "No plaintext passwords found" diff --git a/tests/unit/proxy/auth/test_login_utils.py b/tests/unit/proxy/auth/test_login_utils.py index ccfffaf3999..a74a0ff0641 100644 --- a/tests/unit/proxy/auth/test_login_utils.py +++ b/tests/unit/proxy/auth/test_login_utils.py @@ -2386,14 +2386,14 @@ _SCRYPT_ROW: Final = "scrypt:AAECAwQFBgcICQoLDA0OD7hIwlM/k8ytnffjFDt6AuO+BvqtmZv async def test_login_rehash_rewrites_only_a_legacy_row(stored: str, schemes_written: list[str]) -> None: from litellm.proxy.auth.login_utils import _rehash_password_if_needed - prisma = MagicMock() + prisma: Final = MagicMock() prisma.db.litellm_usertable.update = AsyncMock() with patch( # test-quality-ok: the rehash reads proxy_server's prisma_client global; no injection seam "litellm.proxy.proxy_server.prisma_client", prisma ): await _rehash_password_if_needed("u-1", _PBKDF2_PASSWORD, stored) - updates = prisma.db.litellm_usertable.update.await_args_list + updates: Final = prisma.db.litellm_usertable.update.await_args_list assert [update.kwargs["data"]["password"].split(":")[0] for update in updates] == schemes_written @@ -2405,7 +2405,7 @@ async def test_fips_login_runs_one_pbkdf2_off_the_event_loop_and_writes_no_row( from litellm.proxy.auth.login_utils import authenticate_user monkeypatch.setenv("DATABASE_URL", "postgresql://stub") - repository = _known_user("user@corp.com") + repository: Final = _known_user("user@corp.com") repository.return_value.table.find_first.return_value.password = _PBKDF2_ROW repository.return_value.table.update = AsyncMock() with ( @@ -2419,7 +2419,7 @@ async def test_fips_login_runs_one_pbkdf2_off_the_event_loop_and_writes_no_row( "litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"}) ), ): - result = await authenticate_user( + result: Final = await authenticate_user( username="user@corp.com", password=_PBKDF2_PASSWORD, master_key="sk-master", @@ -2436,9 +2436,9 @@ async def test_fips_login_runs_one_pbkdf2_off_the_event_loop_and_writes_no_row( async def _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch: pytest.MonkeyPatch, row: str): from litellm.proxy.auth.login_utils import authenticate_user - throttle = _throttle(user_limit=1) - repository = _known_user() - user = repository.return_value.table.find_first.return_value + throttle: Final = _throttle(user_limit=1) + repository: Final = _known_user() + user: Final = repository.return_value.table.find_first.return_value user.password = row async def block_the_user_then_return_the_row(**_: object) -> MagicMock: @@ -2491,13 +2491,13 @@ async def test_fips_burst_of_wrong_passwords_stops_deriving_once_its_user_is_blo from litellm.proxy.auth.login_utils import authenticate_user monkeypatch.setenv("DATABASE_URL", "postgresql://stub") - repository = _known_user() + repository: Final = _known_user() repository.return_value.table.find_first.return_value.password = _PBKDF2_ROW - throttle = _throttle(user_limit=1) + throttle: Final = _throttle(user_limit=1) with patch( # test-quality-ok: the user lookup is the database boundary "litellm.proxy.auth.login_utils.UserRepository", repository ): - refusals = await asyncio.gather( + refusals: Final = await asyncio.gather( *( authenticate_user( username="known@example.com", @@ -2520,6 +2520,6 @@ async def test_fips_burst_of_wrong_passwords_stops_deriving_once_its_user_is_blo async def test_standard_login_past_the_throttle_keeps_its_answer_when_its_user_gets_blocked( monkeypatch: pytest.MonkeyPatch, ) -> None: - result = await _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch, _SCRYPT_ROW) + result: Final = await _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch, _SCRYPT_ROW) assert result.key == "sk-ui" diff --git a/tests/unit/proxy/auth/test_password_policy.py b/tests/unit/proxy/auth/test_password_policy.py index 561422c380e..301abe3effd 100644 --- a/tests/unit/proxy/auth/test_password_policy.py +++ b/tests/unit/proxy/auth/test_password_policy.py @@ -9,6 +9,7 @@ httpx.MockTransport, so no network is touched and nothing is monkeypatched. import asyncio import hashlib +from typing import Final import httpx import pytest @@ -360,6 +361,17 @@ async def test_validate_passwords_bulk_empty_batch_makes_no_lookups(): @pytest.mark.asyncio @pytest.mark.usefixtures("fips_openssl") -async def test_fips_mode_skips_the_breach_check() -> None: - client_says_breached = _client_returning(f"{_sha1_upper(STRONG_PASSWORD)[5:]}:1387") - assert await is_password_breached(STRONG_PASSWORD, {}, client_says_breached) is False +async def test_fips_mode_skips_the_breach_check_with_a_warning(caplog: pytest.LogCaptureFixture) -> None: + client_says_breached: Final = _client_returning(f"{_sha1_upper(STRONG_PASSWORD)[5:]}:1387") + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + assert await is_password_breached(STRONG_PASSWORD, {}, client_says_breached) is False + assert [record.levelname for record in caplog.records] == ["WARNING"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("fips_openssl") +async def test_fips_mode_stays_quiet_when_the_breach_check_is_turned_off(caplog: pytest.LogCaptureFixture) -> None: + settings: Final = {"password_policy_check_breached_passwords": False} + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + assert await is_password_breached(STRONG_PASSWORD, settings, _client_never_called()) is False + assert caplog.records == [] diff --git a/tests/unit/proxy/management_endpoints/test_password_endpoints.py b/tests/unit/proxy/management_endpoints/test_password_endpoints.py index fd66419d28e..30c299a11ee 100644 --- a/tests/unit/proxy/management_endpoints/test_password_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_password_endpoints.py @@ -497,7 +497,9 @@ async def test_change_password_requires_db(): async def test_fips_password_change_runs_both_pbkdf2_off_the_event_loop(pbkdf2_on_event_loop: list[bool]) -> None: from litellm.proxy._types import ChangePasswordRequest - prisma = _make_prisma(_make_user_row("pbkdf2:AAECAwQFBgcICQoLDA0OD+uxhNqRo/3ZFLEaKARqFBT+N3Kqek/SGM/5AvocSN0T")) + prisma: Final = _make_prisma( + _make_user_row("pbkdf2:AAECAwQFBgcICQoLDA0OD+uxhNqRo/3ZFLEaKARqFBT+N3Kqek/SGM/5AvocSN0T") + ) with ( patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam diff --git a/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py b/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py index f862b3413c3..cf79e30b3b3 100644 --- a/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py +++ b/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py @@ -239,14 +239,14 @@ def test_fips_hash_is_pbkdf2_sha256_at_600k_salted_by_openssl(monkeypatch: pytes @pytest.mark.usefixtures("fips_openssl") def test_fips_hash_salts_every_row_and_verifies() -> None: - first, second = hash_password(PASSWORD), hash_password(PASSWORD) - assert first != second + first: Final = hash_password(PASSWORD) + assert first != hash_password(PASSWORD) assert verify_password(PASSWORD, first) @pytest.mark.usefixtures("fips_openssl") def test_fips_verify_needs_the_right_password_of_at_least_eight_characters() -> None: - outcome = { + outcome: Final = { "right": verify_password(PASSWORD, PBKDF2_ROW), "wrong": verify_password("Seed-Lamp-4-Rivet", PBKDF2_ROW), "lone_surrogate": verify_password("\ud800" + PASSWORD, PBKDF2_ROW), @@ -307,7 +307,7 @@ def test_standard_mode_verifies_the_rows_it_always_did(row: str, verifies: bool) @pytest.mark.asyncio @pytest.mark.usefixtures("fips_openssl") async def test_fips_boot_migration_writes_no_row() -> None: - pc = MagicMock() + pc: Final = MagicMock() pc.db.litellm_usertable.find_many = AsyncMock( return_value=[_make_user("plaintext-user", PASSWORD), _make_user("fips-user", PBKDF2_ROW)] ) @@ -318,6 +318,20 @@ async def test_fips_boot_migration_writes_no_row() -> None: pc.db.litellm_usertable.update.assert_not_awaited() +@pytest.mark.asyncio +async def test_standard_boot_migration_hashes_a_plaintext_row_but_not_a_pbkdf2_row() -> None: + pc: Final = MagicMock() + pc.db.litellm_usertable.find_many = AsyncMock( + return_value=[_make_user("plaintext-user", "pbkdf2:plaintext"), _make_user("fips-user", PBKDF2_ROW)] + ) + pc.db.litellm_usertable.update = AsyncMock() + + await migrate_passwords_to_scrypt_async(pc) + + updated: Final = pc.db.litellm_usertable.update.await_args_list + assert [update.kwargs["where"]["user_id"] for update in updated] == ["plaintext-user"] + + def _on_event_loop() -> bool: return asyncio._get_running_loop() is not None @@ -329,13 +343,14 @@ async def test_password_kdf_runs_on_the_event_loop_in_standard_mode() -> None: @pytest.mark.asyncio @pytest.mark.usefixtures("fips_openssl") -async def test_password_kdfs_leave_the_event_loop_and_never_overlap_in_fips_mode() -> None: - second_started: Final = threading.Event() +async def test_password_kdfs_leave_the_event_loop_on_a_single_thread_in_fips_mode() -> None: + release_first: Final = threading.Event() + first_kdf: Final = asyncio.ensure_future(run_off_loop_if_fips(lambda: release_first.wait(timeout=5))) + second_kdf: Final = asyncio.ensure_future(run_off_loop_if_fips(_on_event_loop)) + await asyncio.sleep(0) + kdf_threads: Final = [thread for thread in threading.enumerate() if thread.name.startswith("fips-password-kdf")] + release_first.set() - def first() -> tuple[bool, bool]: - return _on_event_loop(), second_started.wait(timeout=0.2) - - (first_on_event_loop, overlapped), _ = await asyncio.gather( - run_off_loop_if_fips(first), run_off_loop_if_fips(second_started.set) - ) - assert (first_on_event_loop, overlapped) == (False, False) + assert len(kdf_threads) == 1 + assert await first_kdf is True + assert await second_kdf is False From 05e35d212201e94f446892a735b53bfac82f7e20 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 15:48:26 -0800 Subject: [PATCH 4/6] fix(proxy): keep the breached password check on in FIPS mode --- litellm/proxy/auth/password_policy.py | 9 +-------- tests/unit/proxy/auth/test_password_policy.py | 15 ++------------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py index a40fa109e82..d810ba4238b 100644 --- a/litellm/proxy/auth/password_policy.py +++ b/litellm/proxy/auth/password_policy.py @@ -24,7 +24,6 @@ from litellm.constants import HIBP_RANGE_API_BASE from litellm.litellm_core_utils.offline_mode import is_offline_mode from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client from litellm.proxy._types import ProxyErrorTypes, ProxyException -from litellm.proxy.common_utils.fips import openssl_enforces_fips from litellm.types.llms.custom_http import httpxSpecialProvider HIBP_TIMEOUT_SECONDS: Final = 5.0 @@ -145,13 +144,7 @@ async def _is_password_breached(password: str, client: AsyncHTTPHandler) -> bool def is_breach_check_enabled(general_settings: Mapping[str, object]) -> bool: - enabled: Final = ( - not is_offline_mode() and general_settings.get("password_policy_check_breached_passwords", True) is not False - ) - if enabled and openssl_enforces_fips(): - verbose_proxy_logger.warning("FIPS mode skips the breached password check because its lookup needs SHA-1.") - return False - return enabled + return not is_offline_mode() and general_settings.get("password_policy_check_breached_passwords", True) is not False async def is_password_breached( diff --git a/tests/unit/proxy/auth/test_password_policy.py b/tests/unit/proxy/auth/test_password_policy.py index 301abe3effd..39e81793f1e 100644 --- a/tests/unit/proxy/auth/test_password_policy.py +++ b/tests/unit/proxy/auth/test_password_policy.py @@ -361,17 +361,6 @@ async def test_validate_passwords_bulk_empty_batch_makes_no_lookups(): @pytest.mark.asyncio @pytest.mark.usefixtures("fips_openssl") -async def test_fips_mode_skips_the_breach_check_with_a_warning(caplog: pytest.LogCaptureFixture) -> None: +async def test_fips_mode_keeps_the_breach_check() -> None: client_says_breached: Final = _client_returning(f"{_sha1_upper(STRONG_PASSWORD)[5:]}:1387") - with caplog.at_level("WARNING", logger="LiteLLM Proxy"): - assert await is_password_breached(STRONG_PASSWORD, {}, client_says_breached) is False - assert [record.levelname for record in caplog.records] == ["WARNING"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") -async def test_fips_mode_stays_quiet_when_the_breach_check_is_turned_off(caplog: pytest.LogCaptureFixture) -> None: - settings: Final = {"password_policy_check_breached_passwords": False} - with caplog.at_level("WARNING", logger="LiteLLM Proxy"): - assert await is_password_breached(STRONG_PASSWORD, settings, _client_never_called()) is False - assert caplog.records == [] + assert await is_password_breached(STRONG_PASSWORD, {}, client_says_breached) is True From 5e0e82bbafda7387c7f74a5276f6d6afc970d055 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 16:16:08 -0800 Subject: [PATCH 5/6] fix(proxy): choose the password KDF from fips_mode instead of the host OpenSSL --- litellm/proxy/auth/login_utils.py | 4 +-- litellm/proxy/common_utils/fips.py | 11 ++++++ litellm/proxy/utils.py | 10 +++--- tests/unit/proxy/auth/test_login_utils.py | 6 ++-- tests/unit/proxy/auth/test_onboarding.py | 2 +- tests/unit/proxy/auth/test_password_policy.py | 2 +- tests/unit/proxy/common_utils/test_fips.py | 35 ++++++++++++++++++- tests/unit/proxy/conftest.py | 9 +++++ .../test_internal_user_endpoints.py | 2 +- .../test_password_endpoints.py | 2 +- .../prisma_and_spend/test_password_helpers.py | 15 +++++--- 11 files changed, 78 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 5ff93eea7f2..7d413121b56 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -32,7 +32,7 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured from litellm.proxy.auth.login_throttle import LoginAttempt, LoginThrottle from litellm.proxy.auth.password_policy import is_breach_check_enabled, is_password_breached -from litellm.proxy.common_utils.fips import openssl_enforces_fips +from litellm.proxy.common_utils.fips import fips_mode_active from litellm.proxy.management_endpoints.internal_user_endpoints import user_update from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, @@ -113,7 +113,7 @@ _ONE_FIPS_LOGIN_AT_A_TIME: Final = asyncio.Lock() async def _password_matches(password: str, stored: str, attempt: LoginAttempt) -> bool: """Checks inline, or in FIPS mode one login at a time, refusing a login whose user got blocked while it waited.""" - if not openssl_enforces_fips(): + if not fips_mode_active(): return verify_password(password, stored) async with _ONE_FIPS_LOGIN_AT_A_TIME: await attempt.refuse_if_blocked_while_waiting() diff --git a/litellm/proxy/common_utils/fips.py b/litellm/proxy/common_utils/fips.py index 1a4d7ddeadb..6cd23e5c0ff 100644 --- a/litellm/proxy/common_utils/fips.py +++ b/litellm/proxy/common_utils/fips.py @@ -1,3 +1,4 @@ +import threading from collections.abc import Mapping from typing import Final @@ -5,6 +6,7 @@ from litellm.secret_managers.main import str_to_bool FIPS_MODE_SETTING: Final = "fips_mode" _SETTING: Final = f"general_settings.{FIPS_MODE_SETTING}" +_FIPS_MODE_ACTIVE: Final = threading.Event() class FipsModeError(Exception): @@ -87,3 +89,12 @@ def enforce_fips_mode(general_settings: Mapping[str, object]) -> None: refusal: Final = fips_mode_refusal(general_settings) if refusal is not None: raise FipsModeError(refusal) + if _fips_mode_setting(general_settings): + _FIPS_MODE_ACTIVE.set() + else: + _FIPS_MODE_ACTIVE.clear() + + +def fips_mode_active() -> bool: + """Whether general_settings.fips_mode was on when enforce_fips_mode last accepted it.""" + return _FIPS_MODE_ACTIVE.is_set() diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 03b82658ec2..64bce0d21f2 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -89,7 +89,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.password_policy import MIN_ALLOWED_LENGTH from litellm.proxy.bug_report_config import build_proxy_bug_report -from litellm.proxy.common_utils.fips import openssl_enforces_fips +from litellm.proxy.common_utils.fips import fips_mode_active from litellm.proxy.common_utils.openai_error_payload import ( attribute_of, error_status_code, @@ -8413,7 +8413,7 @@ def hash_password(password: str) -> str: import hashlib import os - if openssl_enforces_fips(): + if fips_mode_active(): fips_salt: Final = ssl.RAND_bytes(16) fips_dk: Final = hashlib.pbkdf2_hmac("sha256", password.encode(), fips_salt, iterations=600_000, dklen=32) return "pbkdf2:" + base64.b64encode(fips_salt + fips_dk).decode() @@ -8428,7 +8428,7 @@ def verify_password(password: str, stored: str) -> bool: import hashlib import secrets - if openssl_enforces_fips(): + if fips_mode_active(): if not _is_pbkdf2_row(stored): verbose_proxy_logger.error("FIPS mode refuses a non-PBKDF2 password row. Reset it with /user/update.") return False @@ -8458,7 +8458,7 @@ _FIPS_KDF_POOL: Final = ThreadPoolExecutor(max_workers=1, thread_name_prefix="fi async def run_off_loop_if_fips(kdf: Callable[..., _T], *args: str) -> _T: """Run a password KDF inline, or on the single KDF thread in FIPS mode.""" - if not openssl_enforces_fips(): + if not fips_mode_active(): return kdf(*args) return await asyncio.get_running_loop().run_in_executor(_FIPS_KDF_POOL, kdf, *args) @@ -8469,7 +8469,7 @@ async def migrate_passwords_to_scrypt_async(prisma_client) -> str: are left alone (they migrate on next login via the SHA256 fallback). Skips quickly if no plaintext passwords exist. """ - if openssl_enforces_fips(): + if fips_mode_active(): return "Skipped, FIPS mode has no password migration" all_with_pw: Final = await UserRepository(prisma_client).table.find_many( where={"password": {"not": None}}, diff --git a/tests/unit/proxy/auth/test_login_utils.py b/tests/unit/proxy/auth/test_login_utils.py index a74a0ff0641..bd5bdeafe95 100644 --- a/tests/unit/proxy/auth/test_login_utils.py +++ b/tests/unit/proxy/auth/test_login_utils.py @@ -2398,7 +2398,7 @@ async def test_login_rehash_rewrites_only_a_legacy_row(stored: str, schemes_writ @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_login_runs_one_pbkdf2_off_the_event_loop_and_writes_no_row( monkeypatch: pytest.MonkeyPatch, pbkdf2_on_event_loop: list[bool] ) -> None: @@ -2470,7 +2470,7 @@ async def _login_whose_user_gets_blocked_during_the_row_lookup(monkeypatch: pyte @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_login_hides_the_right_password_when_its_user_got_blocked_while_it_waited( monkeypatch: pytest.MonkeyPatch, pbkdf2_on_event_loop: list[bool] ) -> None: @@ -2484,7 +2484,7 @@ async def test_fips_login_hides_the_right_password_when_its_user_got_blocked_whi @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_burst_of_wrong_passwords_stops_deriving_once_its_user_is_blocked( monkeypatch: pytest.MonkeyPatch, pbkdf2_on_event_loop: list[bool] ) -> None: diff --git a/tests/unit/proxy/auth/test_onboarding.py b/tests/unit/proxy/auth/test_onboarding.py index 6f907fab181..c449ffed806 100644 --- a/tests/unit/proxy/auth/test_onboarding.py +++ b/tests/unit/proxy/auth/test_onboarding.py @@ -478,7 +478,7 @@ async def test_claim_token_sets_accepted_at_after_password_written(): @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_claim_hashes_off_the_event_loop(pbkdf2_on_event_loop: list[bool]) -> None: await test_claim_token_sets_accepted_at_after_password_written() assert pbkdf2_on_event_loop == [False] diff --git a/tests/unit/proxy/auth/test_password_policy.py b/tests/unit/proxy/auth/test_password_policy.py index 39e81793f1e..ea60c08a4ea 100644 --- a/tests/unit/proxy/auth/test_password_policy.py +++ b/tests/unit/proxy/auth/test_password_policy.py @@ -360,7 +360,7 @@ async def test_validate_passwords_bulk_empty_batch_makes_no_lookups(): @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_mode_keeps_the_breach_check() -> None: client_says_breached: Final = _client_returning(f"{_sha1_upper(STRONG_PASSWORD)[5:]}:1387") assert await is_password_breached(STRONG_PASSWORD, {}, client_says_breached) is True diff --git a/tests/unit/proxy/common_utils/test_fips.py b/tests/unit/proxy/common_utils/test_fips.py index 0884d0bbbed..d22684ea051 100644 --- a/tests/unit/proxy/common_utils/test_fips.py +++ b/tests/unit/proxy/common_utils/test_fips.py @@ -1,5 +1,5 @@ import _hashlib -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from contextlib import nullcontext from unittest.mock import Mock @@ -12,10 +12,17 @@ from litellm.proxy.common_utils.fips import ( FipsModeError, cryptography_enforces_fips, enforce_fips_mode, + fips_mode_active, openssl_enforces_fips, ) +@pytest.fixture(autouse=True) +def _fips_mode_off_after_each_test() -> Iterator[None]: + yield + enforce_fips_mode({}) + + def _fake_openssl(monkeypatch: pytest.MonkeyPatch, *, fips_flag: int, scrypt_refused: bool) -> None: monkeypatch.setattr(_hashlib, "get_fips_mode", lambda: fips_flag) if scrypt_refused: @@ -119,3 +126,29 @@ def test_enforce_fips_mode( with pytest.raises(FipsModeError, match=refusal) if refusal else nullcontext(): enforce_fips_mode(general_settings) + + +@pytest.mark.parametrize( + "general_settings, active", + [({"fips_mode": True}, True), ({"fips_mode": "true"}, True), ({"fips_mode": False}, False), ({}, False)], + ids=["on", "on-from-a-string", "off", "unset"], +) +def test_fips_mode_active_follows_the_setting_the_gate_accepted( + monkeypatch: pytest.MonkeyPatch, general_settings: Mapping[str, object], active: bool +) -> None: + _fake_openssl(monkeypatch, fips_flag=1, scrypt_refused=True) + monkeypatch.setattr(rust_openssl, "is_fips_enabled", lambda: True) + enforce_fips_mode(general_settings) + assert fips_mode_active() is active + + +def test_fips_mode_stays_inactive_on_a_refused_boot(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_openssl(monkeypatch, fips_flag=0, scrypt_refused=False) + with pytest.raises(FipsModeError): + enforce_fips_mode({"fips_mode": True}) + assert fips_mode_active() is False + + +def test_a_fips_openssl_alone_does_not_activate_fips_mode(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_openssl(monkeypatch, fips_flag=1, scrypt_refused=True) + assert fips_mode_active() is False diff --git a/tests/unit/proxy/conftest.py b/tests/unit/proxy/conftest.py index 7f89bb2e459..e45ae2dd8fd 100644 --- a/tests/unit/proxy/conftest.py +++ b/tests/unit/proxy/conftest.py @@ -23,6 +23,7 @@ from prisma.errors import ClientNotConnectedError import litellm import litellm.proxy.proxy_server from litellm._logging import verbose_proxy_logger +from litellm.proxy.common_utils.fips import enforce_fips_mode from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( InitPassThroughEndpointHelpers, _registered_pass_through_routes, @@ -531,6 +532,14 @@ def fips_openssl(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(_hashlib, "new", new) +@pytest.fixture +def fips_mode(fips_openssl: None, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setattr("litellm.proxy.common_utils.fips.cryptography_enforces_fips", lambda: True) + enforce_fips_mode({"fips_mode": True}) + yield + enforce_fips_mode({}) + + @pytest.fixture def pbkdf2_on_event_loop(monkeypatch: pytest.MonkeyPatch) -> list[bool]: """One entry per PBKDF2 call: whether it ran on the event loop's thread.""" diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index b1d4e89bde6..120cf8a2b84 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -5486,7 +5486,7 @@ async def test_user_update_reports_a_bad_budget_duration_as_a_request_error_not_ @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_user_update_hashes_off_the_event_loop(pbkdf2_on_event_loop: list[bool]) -> None: from litellm.proxy.management_endpoints.internal_user_endpoints import _hash_password_in_dict diff --git a/tests/unit/proxy/management_endpoints/test_password_endpoints.py b/tests/unit/proxy/management_endpoints/test_password_endpoints.py index 30c299a11ee..d1e9f80129b 100644 --- a/tests/unit/proxy/management_endpoints/test_password_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_password_endpoints.py @@ -493,7 +493,7 @@ async def test_change_password_requires_db(): @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_password_change_runs_both_pbkdf2_off_the_event_loop(pbkdf2_on_event_loop: list[bool]) -> None: from litellm.proxy._types import ChangePasswordRequest diff --git a/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py b/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py index cf79e30b3b3..2df009a72a9 100644 --- a/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py +++ b/tests/unit/proxy/utils/prisma_and_spend/test_password_helpers.py @@ -232,19 +232,24 @@ async def test_migrate_passwords_raises_on_db_failure() -> None: @pytest.mark.usefixtures("fips_openssl") +def test_a_fips_openssl_without_fips_mode_stays_on_scrypt() -> None: + assert hash_password(PASSWORD).startswith("scrypt:") + + +@pytest.mark.usefixtures("fips_mode") def test_fips_hash_is_pbkdf2_sha256_at_600k_salted_by_openssl(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ssl, "RAND_bytes", lambda size: bytes(range(size))) assert hash_password(PASSWORD) == PBKDF2_ROW -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") def test_fips_hash_salts_every_row_and_verifies() -> None: first: Final = hash_password(PASSWORD) assert first != hash_password(PASSWORD) assert verify_password(PASSWORD, first) -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") def test_fips_verify_needs_the_right_password_of_at_least_eight_characters() -> None: outcome: Final = { "right": verify_password(PASSWORD, PBKDF2_ROW), @@ -262,7 +267,7 @@ def test_fips_verify_needs_the_right_password_of_at_least_eight_characters() -> } -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") @pytest.mark.parametrize( "row", [ @@ -305,7 +310,7 @@ def test_standard_mode_verifies_the_rows_it_always_did(row: str, verifies: bool) @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_fips_boot_migration_writes_no_row() -> None: pc: Final = MagicMock() pc.db.litellm_usertable.find_many = AsyncMock( @@ -342,7 +347,7 @@ async def test_password_kdf_runs_on_the_event_loop_in_standard_mode() -> None: @pytest.mark.asyncio -@pytest.mark.usefixtures("fips_openssl") +@pytest.mark.usefixtures("fips_mode") async def test_password_kdfs_leave_the_event_loop_on_a_single_thread_in_fips_mode() -> None: release_first: Final = threading.Event() first_kdf: Final = asyncio.ensure_future(run_off_loop_if_fips(lambda: release_first.wait(timeout=5))) From 0b8d9ad8905aadc8e69b431c06af085a3270c08d Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 5 Nov 2026 17:47:44 -0800 Subject: [PATCH 6/6] test(proxy): fake the scrypt probe in the FIPS fixture now that the gate checks scrypt --- tests/unit/proxy/conftest.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/unit/proxy/conftest.py b/tests/unit/proxy/conftest.py index e45ae2dd8fd..d08a98242ef 100644 --- a/tests/unit/proxy/conftest.py +++ b/tests/unit/proxy/conftest.py @@ -521,15 +521,8 @@ def postgres_span_names() -> Iterator[Callable[[], Awaitable[tuple[str, ...]]]]: @pytest.fixture def fips_openssl(monkeypatch: pytest.MonkeyPatch) -> None: - real_new: Final = _hashlib.new - - def new(name: str, data: bytes = b"", *, usedforsecurity: bool = True) -> _hashlib.HASH: - if name == "md5": - raise _hashlib.UnsupportedDigestmodError("unsupported hash type") - return real_new(name, data, usedforsecurity=usedforsecurity) - monkeypatch.setattr(_hashlib, "get_fips_mode", lambda: 1) - monkeypatch.setattr(_hashlib, "new", new) + monkeypatch.setattr(_hashlib, "scrypt", MagicMock(side_effect=ValueError("[digital envelope routines] unsupported"))) @pytest.fixture