fix(proxy): point users who must rotate at the rotation guide before they save a new key

This commit is contained in:
ryan-crabbe-berri 2026-09-19 13:49:32 -07:00
parent 0415382f9e
commit 186ba50bce
3 changed files with 35 additions and 8 deletions

View file

@ -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 = (

View file

@ -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"))

View file

@ -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