mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
* 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 * 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. * 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. * fix(proxy): keep the breached password check on in FIPS mode * fix(proxy): choose the password KDF from fips_mode instead of the host OpenSSL * test(proxy): fake the scrypt probe in the FIPS fixture now that the gate checks scrypt
154 lines
6 KiB
Python
154 lines
6 KiB
Python
import _hashlib
|
|
from collections.abc import Iterator, Mapping
|
|
from contextlib import nullcontext
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from cryptography.exceptions import InternalError
|
|
from cryptography.hazmat.bindings._rust import openssl as rust_openssl
|
|
from cryptography.hazmat.primitives import hashes
|
|
|
|
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:
|
|
monkeypatch.setattr(_hashlib, "scrypt", Mock(side_effect=ValueError("[digital envelope routines] unsupported")))
|
|
else:
|
|
monkeypatch.setattr(_hashlib, "scrypt", Mock(return_value=b"\0" * 16))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fips_flag, scrypt_refused, enforces",
|
|
[(1, True, True), (0, True, False), (1, False, False)],
|
|
ids=["fips", "fips-flag-off", "scrypt-allowed"],
|
|
)
|
|
def test_openssl_enforces_fips_needs_the_fips_flag_and_a_refused_scrypt(
|
|
monkeypatch: pytest.MonkeyPatch, fips_flag: int, scrypt_refused: bool, enforces: bool
|
|
) -> None:
|
|
_fake_openssl(monkeypatch, fips_flag=fips_flag, scrypt_refused=scrypt_refused)
|
|
assert openssl_enforces_fips() is enforces
|
|
|
|
|
|
def test_openssl_enforces_fips_while_md5_is_still_served_for_non_security_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_fake_openssl(monkeypatch, fips_flag=1, scrypt_refused=True)
|
|
monkeypatch.setattr(_hashlib, "new", Mock(return_value=Mock()))
|
|
assert openssl_enforces_fips() is True
|
|
|
|
|
|
def test_openssl_without_get_fips_mode_does_not_enforce_fips(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delattr(_hashlib, "get_fips_mode")
|
|
assert openssl_enforces_fips() is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fips_enabled, sha256_broken, enforces",
|
|
[(True, False, True), (False, False, False), (True, True, False)],
|
|
ids=["fips", "fips-flag-off", "sha256-broken"],
|
|
)
|
|
def test_cryptography_enforces_fips_needs_the_fips_flag_and_a_working_sha256(
|
|
monkeypatch: pytest.MonkeyPatch, fips_enabled: bool, sha256_broken: bool, enforces: bool
|
|
) -> None:
|
|
monkeypatch.setattr(rust_openssl, "is_fips_enabled", lambda: fips_enabled)
|
|
if sha256_broken:
|
|
monkeypatch.setattr(hashes, "Hash", Mock(side_effect=InternalError("Unknown OpenSSL error", [])))
|
|
assert cryptography_enforces_fips() is enforces
|
|
|
|
|
|
def test_cryptography_without_is_fips_enabled_does_not_enforce_fips(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delattr(rust_openssl, "is_fips_enabled")
|
|
assert cryptography_enforces_fips() is False
|
|
|
|
|
|
def test_this_runners_python_openssl_does_not_enforce_fips() -> None:
|
|
if _hashlib.get_fips_mode() == 1:
|
|
pytest.skip("this runner's Python OpenSSL is in FIPS mode")
|
|
assert openssl_enforces_fips() is False
|
|
|
|
|
|
def test_this_runners_cryptography_openssl_does_not_enforce_fips() -> None:
|
|
if rust_openssl.is_fips_enabled():
|
|
pytest.skip("this runner's cryptography OpenSSL is in FIPS mode")
|
|
assert cryptography_enforces_fips() is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"general_settings, openssl_fips, cryptography_fips, refusal",
|
|
[
|
|
({}, False, False, None),
|
|
({"fips_mode": False}, False, False, None),
|
|
({"fips_mode": "false"}, False, False, None),
|
|
({"fips_mode": True}, True, True, None),
|
|
({"fips_mode": "true"}, True, True, None),
|
|
({"fips_mode": True}, False, True, "general_settings.fips_mode is on but OpenSSL does not enforce FIPS"),
|
|
({"fips_mode": " TRUE "}, False, True, "general_settings.fips_mode is on but OpenSSL does not enforce FIPS"),
|
|
({"fips_mode": True}, True, False, "cryptography library's OpenSSL has no working FIPS provider"),
|
|
({"fips_mode": "enforced"}, True, True, "general_settings.fips_mode='enforced' is not a boolean"),
|
|
({"fips_mode": 1}, True, True, "general_settings.fips_mode=1 is not a boolean"),
|
|
({"fips_mode": None}, True, True, "general_settings.fips_mode has no value"),
|
|
],
|
|
ids=[
|
|
"unset",
|
|
"false",
|
|
"false-from-a-string",
|
|
"fips",
|
|
"fips-from-a-string",
|
|
"openssl-not-fips",
|
|
"openssl-not-fips-from-a-padded-string",
|
|
"cryptography-not-fips",
|
|
"not-a-boolean",
|
|
"a-number",
|
|
"no-value",
|
|
],
|
|
)
|
|
def test_enforce_fips_mode(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
general_settings: Mapping[str, object],
|
|
openssl_fips: bool,
|
|
cryptography_fips: bool,
|
|
refusal: str | None,
|
|
) -> None:
|
|
_fake_openssl(monkeypatch, fips_flag=int(openssl_fips), scrypt_refused=openssl_fips)
|
|
monkeypatch.setattr(rust_openssl, "is_fips_enabled", lambda: cryptography_fips)
|
|
|
|
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
|