From 186ba50bce4f3af3b820bc0ec14ee94d3d873e3a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 19 Sep 2026 13:49:32 -0700 Subject: [PATCH] fix(proxy): point users who must rotate at the rotation guide before they save a new key --- litellm/proxy/auth/master_key_boot_check.py | 16 +++++++----- .../proxy/auth/test_master_key_boot_check.py | 25 ++++++++++++++++++- tests/test_litellm/proxy/test_proxy_server.py | 2 +- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/auth/master_key_boot_check.py b/litellm/proxy/auth/master_key_boot_check.py index 0ad63fd2e19..971e6bcde36 100644 --- a/litellm/proxy/auth/master_key_boot_check.py +++ b/litellm/proxy/auth/master_key_boot_check.py @@ -17,7 +17,9 @@ MASTER_KEY_ENV_VAR: Final = "LITELLM_MASTER_KEY" SALT_KEY_ENV_VAR: Final = "LITELLM_SALT_KEY" PUBLICLY_KNOWN_MASTER_KEYS: Final = frozenset({"sk-1234"}) ROTATION_DOCS_URL: Final = "https://docs.litellm.ai/docs/proxy/master_key_rotations#proxy-refuses-to-start" -GENERATE_MASTER_KEY_COMMAND: Final = f'echo "{MASTER_KEY_ENV_VAR}=sk-$(openssl rand -hex 32)" | tee -a .env' +_NEW_MASTER_KEY: Final = "sk-$(openssl rand -hex 32)" +GENERATE_MASTER_KEY_COMMAND: Final = f'echo "{MASTER_KEY_ENV_VAR}={_NEW_MASTER_KEY}" | tee -a .env' +PRINT_NEW_MASTER_KEY_COMMAND: Final = f'echo "{_NEW_MASTER_KEY}"' class UnsafeMasterKeyReason(Enum): @@ -129,8 +131,7 @@ def render_refusal(refusal: UnsafeMasterKeyRefused) -> str: return "\n\n".join( ( f"LiteLLM proxy refused to start: {_REFUSAL_HEADLINE[refusal.reason]}\n{_source_line(refusal)}", - _fix_steps(refusal.source), - *((_ROTATION_WARNING,) if refusal.stored_credentials_need_rotation else ()), + _ROTATE_INSTEAD_OF_REPLACING if refusal.stored_credentials_need_rotation else _fix_steps(refusal.source), _OVERRIDE_HINT, ) ) @@ -161,9 +162,12 @@ _SAVE_KEY_STEP: Final = ( f" {MASTER_KEY_ENV_VAR} environment variable instead." ) -_ROTATION_WARNING: Final = ( - f"Credentials stored in your database are encrypted with the current master key because {SALT_KEY_ENV_VAR}\n" - f"is not set. Rotate the key before changing it, or they become undecryptable:\n{ROTATION_DOCS_URL}" +_ROTATE_INSTEAD_OF_REPLACING: Final = ( + f"Credentials stored in your database are encrypted with this master key because {SALT_KEY_ENV_VAR} is not\n" + "set, so replacing the key makes them undecryptable. Rotate it by following this guide, which re-encrypts them:\n" + f" {ROTATION_DOCS_URL}\n" + "Generate the new key for it with (save it only once the guide says to):\n" + f" {PRINT_NEW_MASTER_KEY_COMMAND}" ) _OVERRIDE_HINT: Final = ( diff --git a/tests/test_litellm/proxy/auth/test_master_key_boot_check.py b/tests/test_litellm/proxy/auth/test_master_key_boot_check.py index 0bca702f73f..fcd209d3d39 100644 --- a/tests/test_litellm/proxy/auth/test_master_key_boot_check.py +++ b/tests/test_litellm/proxy/auth/test_master_key_boot_check.py @@ -9,6 +9,7 @@ import pytest from litellm.proxy.auth.master_key_boot_check import ( GENERATE_MASTER_KEY_COMMAND, MASTER_KEY_ENV_VAR, + PRINT_NEW_MASTER_KEY_COMMAND, ROTATION_DOCS_URL, UNSAFE_PROXY_OVERRIDE_ENV_VAR, UNSAFE_PROXY_OVERRIDE_SETTING, @@ -172,7 +173,7 @@ def test_unset_key_refusal_says_nothing_supplied_one(): assert "Neither general_settings.master_key nor" in text -def test_rotation_warning_appears_only_when_needed(): +def test_rotation_guide_appears_only_when_needed(): with_rotation = render_refusal(_refusal(stored_credentials_need_rotation=True)) without_rotation = render_refusal(_refusal(stored_credentials_need_rotation=False)) @@ -180,6 +181,17 @@ def test_rotation_warning_appears_only_when_needed(): assert ROTATION_DOCS_URL not in without_rotation +@pytest.mark.parametrize("source", [EnvironmentSource(), ConfigFileSource(config_file_path="/app/config.yaml")]) +def test_refusal_never_tells_a_user_who_must_rotate_to_save_the_new_key_first( + source: ConfigFileSource | EnvironmentSource, +): + text = render_refusal(_refusal(source=source, stored_credentials_need_rotation=True)) + + assert PRINT_NEW_MASTER_KEY_COMMAND in text + assert ".env" not in text + assert "os.environ/" not in text + + @pytest.mark.parametrize("stored_credentials_need_rotation", [True, False]) def test_override_hint_is_the_last_paragraph(stored_credentials_need_rotation: bool): text = render_refusal(_refusal(stored_credentials_need_rotation=stored_credentials_need_rotation)) @@ -202,6 +214,17 @@ def test_printed_command_saves_a_key_the_boot_check_accepts(tmp_path: Path): assert _verdict(match.group(1)) == SafeMasterKey() +@pytest.mark.skipif(shutil.which("openssl") is None, reason="the printed command shells out to openssl") +def test_rotation_command_prints_a_key_the_boot_check_accepts_and_saves_nothing(tmp_path: Path): + completed = subprocess.run( + ["bash", "-c", PRINT_NEW_MASTER_KEY_COMMAND], cwd=tmp_path, capture_output=True, text=True, check=True + ) + + assert re.fullmatch(r"sk-[0-9a-f]{64}\n", completed.stdout) is not None + assert _verdict(completed.stdout.strip()) == SafeMasterKey() + assert list(tmp_path.iterdir()) == [] + + def test_refusal_announces_the_fix_and_aborts_the_boot(): announced: list[str] = [] refusal = _refusal(source=ConfigFileSource(config_file_path="/app/config.yaml")) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 0d440dd14eb..07a07a25764 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -1663,7 +1663,7 @@ async def test_proxy_startup_refuses_an_unsafe_master_key_before_connecting_to_t pass assert len(announced) == 1 - assert "LITELLM_MASTER_KEY=sk-$(openssl rand -hex 32)" in announced[0] + assert "sk-$(openssl rand -hex 32)" in announced[0] @pytest.mark.asyncio