fix(mcp): default the legacy KDF grace window on, force off under FIPS, cache the legacy derivation

legacy_kdf_grace_enabled required LITELLM_MCP_LEGACY_KDF_GRACE=true, so
operators who never heard of the variable lost every main-era scrypt token
on upgrade day. The window now defaults on and only explicit false-style
values (false/0/no/off) disable it; FIPS deployments stay force-off via the
existing is_fips_mode check. legacy_scrypt (n=2**15, memory-hard) is now
lru_cache'd per process so repeated legacy-format token attempts derive the
subkeys once instead of re-running scrypt per attempt.
This commit is contained in:
yucheng-berri 2026-10-14 02:03:10 -07:00
parent 48e3d911b5
commit 3d589dc35b
5 changed files with 218 additions and 38 deletions

View file

@ -2,13 +2,14 @@
Bridge envelopes and gateway session tokens are keyed by HKDF-SHA256 (RFC 5869) over the
high-entropy proxy ``master_key`` with a per-family domain label as ``info``. The legacy
scrypt derivation is kept for one release as an opt-in read fallback so tokens minted
scrypt derivation is kept for one release as a default-on read fallback so tokens minted
before the rotation still open during the grace window; it is never used under FIPS.
"""
import hashlib
import os
from collections.abc import Callable
from functools import lru_cache
from typing import Final
from cryptography.hazmat.primitives.hashes import SHA256
@ -18,6 +19,7 @@ from litellm.proxy.common_utils.fips import is_fips_mode
LEGACY_KDF_GRACE_ENV_VAR: Final = "LITELLM_MCP_LEGACY_KDF_GRACE"
DERIVED_KEY_BYTES: Final = 32
_LEGACY_GRACE_DISABLED_VALUES: Final = frozenset({"false", "0", "no", "off"})
# RFC 7914 work factors of the pre-rotation derivation; maxmem is twice the working set.
_SCRYPT_N: Final = 2**15
@ -31,9 +33,12 @@ def hkdf_sha256(master_key: str, info: bytes) -> str:
return HKDF(algorithm=SHA256(), length=DERIVED_KEY_BYTES, salt=None, info=info).derive(master_key.encode()).hex()
@lru_cache(maxsize=8)
def legacy_scrypt(master_key: str, salt: bytes) -> str | None:
"""The pre-rotation derivation. ``None`` when hashlib.scrypt is unavailable (some
OpenSSL builds, including FIPS providers, do not expose it)."""
OpenSSL builds, including FIPS providers, do not expose it). Cached per process: the
work factors are memory-hard, so repeated legacy-format token attempts must not
re-derive the same subkeys."""
try:
return hashlib.scrypt(
master_key.encode(),
@ -49,8 +54,11 @@ def legacy_scrypt(master_key: str, salt: bytes) -> str | None:
def legacy_kdf_grace_enabled(environ: Callable[[str], str | None] = os.environ.get) -> bool:
"""True only when the operator opted the legacy read fallback in and the deployment is
not in FIPS mode (scrypt is not a FIPS-approved KDF)."""
"""True unless the operator explicitly disabled the legacy read fallback and the
deployment is not in FIPS mode (scrypt is not a FIPS-approved KDF). The window
defaults on so main-era tokens survive the upgrade; only ``false``-style values
(``false``/``0``/``no``/``off``) turn it off."""
if is_fips_mode(environ):
return False
return (environ(LEGACY_KDF_GRACE_ENV_VAR) or "").strip().lower() == "true"
raw: Final = environ(LEGACY_KDF_GRACE_ENV_VAR)
return raw is None or raw.strip().lower() not in _LEGACY_GRACE_DISABLED_VALUES

View file

@ -54,6 +54,7 @@ ENVELOPE_SIGNING: Final = b"litellm-mcp-bridge:envelope-signing:"
ENVELOPE_ENCRYPTION: Final = b"litellm-mcp-bridge:envelope-encryption:"
SESSION_SIGNING: Final = b"litellm-mcp-gateway:session-signing:"
GRACE: Final = {"LITELLM_MCP_LEGACY_KDF_GRACE": "true"}
NO_GRACE: Final = {"LITELLM_MCP_LEGACY_KDF_GRACE": "false"}
class _RefreshGrantForm(TypedDict):
@ -239,14 +240,39 @@ def test_envelope_minted_under_hkdf_sha256_keys_is_admitted(gateway: Gateway) ->
_assert_admitted(_tools_list(gateway.client, f"/{alias}/mcp", envelope), peer, upstream_token)
def test_envelope_minted_under_legacy_scrypt_keys_is_rejected_without_grace(gateway: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
def test_envelope_minted_under_legacy_scrypt_keys_is_rejected_when_grace_is_explicitly_disabled(
gateway: Gateway, tmp_path: Path
) -> None:
with (
owned_proxy(gateway, tmp_path, NO_GRACE) as strict,
mcp_peer() as peer,
oauth_server() as auth,
strict.scenario() as scenario,
):
alias: Final = "kdf" + uuid.uuid4().hex[:8]
identity: Final = _register_bridge(scenario, peer, alias, auth.issuer)
key: Final = scenario.key(user_id=scenario.user(), object_permission=_ObjectPermission(mcp_servers=(identity,)))
peer.drain()
envelope: Final = _envelope(identity, key, _scrypt_envelope_keys(gateway.key), "up-" + uuid.uuid4().hex)
_assert_rejected(_tools_list(gateway.client, f"/{alias}/mcp", envelope), peer)
envelope: Final = _envelope(identity, key, _scrypt_envelope_keys(strict.key), "up-" + uuid.uuid4().hex)
_assert_rejected(_tools_list(strict.client, f"/{alias}/mcp", envelope), peer)
def test_envelope_minted_under_legacy_scrypt_keys_is_admitted_by_default_without_the_grace_variable(
gateway: Gateway, tmp_path: Path
) -> None:
with (
owned_proxy(gateway, tmp_path, {}) as plain,
mcp_peer() as peer,
oauth_server() as auth,
plain.scenario() as scenario,
):
alias: Final = "kdf" + uuid.uuid4().hex[:8]
identity: Final = _register_bridge(scenario, peer, alias, auth.issuer)
key: Final = scenario.key(user_id=scenario.user(), object_permission=_ObjectPermission(mcp_servers=(identity,)))
upstream_token: Final = "up-" + uuid.uuid4().hex
peer.drain()
envelope: Final = _envelope(identity, key, _scrypt_envelope_keys(plain.key), upstream_token)
_assert_admitted(_tools_list(plain.client, f"/{alias}/mcp", envelope), peer, upstream_token)
@pytest.mark.parametrize("kdf", ("hkdf", "scrypt"))
@ -285,11 +311,11 @@ def test_envelope_under_a_foreign_master_key_is_rejected_during_grace(gateway: G
_assert_rejected(_tools_list(graced.client, f"/{alias}/mcp", _envelope(identity, key, keys, "up")), peer)
def test_grace_variable_set_to_anything_but_true_keeps_legacy_envelopes_rejected(
def test_grace_variable_set_to_an_explicit_false_keeps_legacy_envelopes_rejected(
gateway: Gateway, tmp_path: Path
) -> None:
with (
owned_proxy(gateway, tmp_path, {"LITELLM_MCP_LEGACY_KDF_GRACE": "yes"}) as proxy,
owned_proxy(gateway, tmp_path, {"LITELLM_MCP_LEGACY_KDF_GRACE": "false"}) as proxy,
mcp_peer() as peer,
oauth_server() as auth,
proxy.scenario() as scenario,
@ -310,14 +336,26 @@ def test_session_token_minted_under_hkdf_sha256_key_is_admitted(gateway: Gateway
assert "tools" in response.text, response.text
def test_session_token_minted_under_legacy_scrypt_key_is_rejected_without_grace(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
token: Final = _session(scenario.user(), _scrypt_session_keys(gateway.key))
response: Final = _tools_list(gateway.client, "/mcp", token)
def test_session_token_minted_under_legacy_scrypt_key_is_rejected_when_grace_is_explicitly_disabled(
gateway: Gateway, tmp_path: Path
) -> None:
with owned_proxy(gateway, tmp_path, NO_GRACE) as strict, strict.scenario() as scenario:
token: Final = _session(scenario.user(), _scrypt_session_keys(strict.key))
response: Final = _tools_list(strict.client, "/mcp", token)
assert response.status_code == 401, response.text
assert "invalid_token" in response.headers.get("www-authenticate", ""), response.headers
def test_session_token_minted_under_legacy_scrypt_key_is_admitted_by_default_without_the_grace_variable(
gateway: Gateway, tmp_path: Path
) -> None:
with owned_proxy(gateway, tmp_path, {}) as plain, plain.scenario() as scenario:
token: Final = _session(scenario.user(), _scrypt_session_keys(plain.key))
response: Final = _tools_list(plain.client, "/mcp", token)
assert response.status_code == 200, response.text
assert "tools" in response.text, response.text
@pytest.mark.parametrize("kdf", ("hkdf", "scrypt"))
def test_session_token_of_either_kdf_is_admitted_during_the_legacy_grace_window(
gateway: Gateway, tmp_path: Path, kdf: str
@ -358,15 +396,21 @@ def test_bridge_refresh_envelope_minted_under_hkdf_sha256_keys_is_renewed(gatewa
assert isinstance(opened, OpenedRefreshEnvelope), opened
def test_bridge_refresh_envelope_minted_under_legacy_scrypt_keys_is_rejected_without_grace(
def test_bridge_refresh_envelope_minted_under_legacy_scrypt_keys_is_rejected_when_grace_is_explicitly_disabled(
gateway: Gateway,
tmp_path: Path,
) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
with (
owned_proxy(gateway, tmp_path, NO_GRACE) as strict,
mcp_peer() as peer,
oauth_server() as auth,
strict.scenario() as scenario,
):
alias: Final = "kdf" + uuid.uuid4().hex[:8]
identity: Final = _register_bridge(scenario, peer, alias, auth.issuer)
key: Final = scenario.key(user_id=scenario.user(), object_permission=_ObjectPermission(mcp_servers=(identity,)))
envelope: Final = _refresh_envelope(identity, key, _scrypt_envelope_keys(gateway.key), _upstream_refresh(auth))
response: Final = gateway.client.post(
envelope: Final = _refresh_envelope(identity, key, _scrypt_envelope_keys(strict.key), _upstream_refresh(auth))
response: Final = strict.client.post(
f"/{alias}/token",
headers=(("x-litellm-api-key", key),),
data=_RefreshGrantForm(grant_type="refresh_token", refresh_token=envelope, client_id="kdf-client"),
@ -430,11 +474,13 @@ def test_session_refresh_token_minted_under_hkdf_sha256_key_is_renewed(gateway:
assert isinstance(opened, OpenedSessionToken), opened
def test_session_refresh_token_minted_under_legacy_scrypt_key_is_rejected_without_grace(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
client_id: Final = _register_gateway_client(gateway.client)
refresh: Final = _session_refresh(scenario.user(), client_id, _scrypt_session_keys(gateway.key))
response: Final = gateway.client.post(
def test_session_refresh_token_minted_under_legacy_scrypt_key_is_rejected_when_grace_is_explicitly_disabled(
gateway: Gateway, tmp_path: Path
) -> None:
with owned_proxy(gateway, tmp_path, NO_GRACE) as strict, strict.scenario() as scenario:
client_id: Final = _register_gateway_client(strict.client)
refresh: Final = _session_refresh(scenario.user(), client_id, _scrypt_session_keys(strict.key))
response: Final = strict.client.post(
"/token", data=_RefreshGrantForm(grant_type="refresh_token", refresh_token=refresh, client_id=client_id)
)
assert response.status_code == 400, response.text
@ -536,7 +582,21 @@ def test_claude_code_gateway_refresh_of_an_hkdf_session_token_is_renewed_without
assert isinstance(opened, OpenedSessionToken), opened
def test_claude_code_gateway_refresh_of_a_legacy_scrypt_session_token_is_rejected_without_grace(
def test_claude_code_gateway_refresh_of_a_legacy_scrypt_session_token_is_rejected_when_grace_is_explicitly_disabled(
gateway: Gateway, tmp_path: Path
) -> None:
config: Final = _claude_code_gateway_config(tmp_path)
with owned_proxy(gateway, tmp_path, NO_GRACE, config=config) as graced, graced.scenario() as scenario:
refresh: Final = _proxy_api_session_refresh(scenario.user(), _scrypt_session_keys(graced.key))
response: Final = graced.client.post(
"/claude_code_gateway/oauth/token",
data=_ClaudeCodeTokenForm(grant_type="refresh_token", refresh_token=refresh),
)
assert response.status_code == 400, response.text
assert response.json()["error"] == "invalid_grant", response.text
def test_claude_code_gateway_refresh_of_a_legacy_scrypt_session_token_is_renewed_by_default_without_the_grace_variable(
gateway: Gateway, tmp_path: Path
) -> None:
config: Final = _claude_code_gateway_config(tmp_path)
@ -546,8 +606,12 @@ def test_claude_code_gateway_refresh_of_a_legacy_scrypt_session_token_is_rejecte
"/claude_code_gateway/oauth/token",
data=_ClaudeCodeTokenForm(grant_type="refresh_token", refresh_token=refresh),
)
assert response.status_code == 400, response.text
assert response.json()["error"] == "invalid_grant", response.text
assert response.status_code == 200, response.text
renewed: Final = response.json()
opened: Final = open_session_refresh_token(
renewed["refresh_token"], _hkdf_session_keys(graced.key), datetime.now(timezone.utc)
)
assert isinstance(opened, OpenedSessionToken), opened
def test_claude_code_gateway_refresh_of_a_legacy_scrypt_session_token_is_renewed_during_the_legacy_grace_window(

View file

@ -411,13 +411,29 @@ def test_legacy_envelope_opens_during_the_grace_window():
assert result.identity == _IDENTITY
def test_legacy_envelope_is_invalid_without_grace():
def test_legacy_envelope_is_invalid_when_grace_is_explicitly_disabled():
token: Final = _sealed_token(_legacy_keys(_MASTER_KEY))
assert legacy_envelope_keys_from_master_key(_MASTER_KEY, environ=_environ(MappingProxyType({}))) is None
assert (
legacy_envelope_keys_from_master_key(
_MASTER_KEY, environ=_environ(MappingProxyType({LEGACY_KDF_GRACE_ENV_VAR: "false"}))
)
is None
)
result: Final = resolve_bridge_envelope(token, envelope_keys_from_master_key(_MASTER_KEY), _NOW, _SERVER_ID)
assert isinstance(result, BridgeEnvelopeInvalid)
def test_legacy_envelope_opens_by_default_without_the_grace_variable():
legacy: Final = _legacy_keys(_MASTER_KEY)
token: Final = _sealed_token(legacy)
grace: Final = legacy_envelope_keys_from_master_key(_MASTER_KEY, environ=_environ(MappingProxyType({})))
assert grace == legacy
result: Final = resolve_bridge_envelope(
token, envelope_keys_from_master_key(_MASTER_KEY), _NOW, _SERVER_ID, legacy_keys=grace
)
assert isinstance(result, BridgeEnvelopeAdmitted)
def test_grace_is_disabled_under_fips():
grace: Final = legacy_envelope_keys_from_master_key(
_MASTER_KEY,
@ -426,13 +442,20 @@ def test_grace_is_disabled_under_fips():
assert grace is None
@pytest.mark.parametrize("value", ("yes", "1", "on", "TRUE ", "True"))
def test_grace_is_enabled_only_by_case_insensitive_true(value: str):
@pytest.mark.parametrize("value", ("false", "0", "no", "off", "FALSE ", " Off"))
def test_grace_is_disabled_only_by_explicit_false_style_values(value: str):
result: Final = legacy_envelope_keys_from_master_key(
_MASTER_KEY, environ=_environ(MappingProxyType({LEGACY_KDF_GRACE_ENV_VAR: value}))
)
expected: Final = value.strip().lower() == "true"
assert (result is not None) is expected
assert result is None
@pytest.mark.parametrize("value", ("true", "1", "yes", "on", "TRUE ", "True", "", "banana"))
def test_grace_stays_on_for_unset_true_style_and_unrecognized_values(value: str):
result: Final = legacy_envelope_keys_from_master_key(
_MASTER_KEY, environ=_environ(MappingProxyType({LEGACY_KDF_GRACE_ENV_VAR: value}))
)
assert result is not None
def test_legacy_refresh_envelope_opens_during_grace():

View file

@ -17,6 +17,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credenti
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.key_derivation import (
LEGACY_KDF_GRACE_ENV_VAR,
legacy_scrypt,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
NotSessionBearer,
@ -276,10 +277,15 @@ def test_legacy_session_token_opens_during_the_grace_window():
assert result.principal == PRINCIPAL
def test_legacy_session_token_is_invalid_without_grace():
def test_legacy_session_token_is_invalid_when_grace_is_explicitly_disabled():
minted: Final = mint_session_token(PRINCIPAL, _legacy_session_keys(MASTER_KEY), NOW)
assert isinstance(minted, MintedSessionToken)
assert legacy_session_keys_from_master_key(MASTER_KEY, KEYS, environ=_environ(MappingProxyType({}))) is None
assert (
legacy_session_keys_from_master_key(
MASTER_KEY, KEYS, environ=_environ(MappingProxyType({LEGACY_KDF_GRACE_ENV_VAR: "false"}))
)
is None
)
result: Final = resolve_session_bearer(f"Bearer {minted.token.get_secret_value()}", KEYS, NOW)
assert isinstance(result, SessionBearerInvalid)
assert result.expired is False
@ -302,6 +308,43 @@ def test_grace_is_disabled_under_fips():
assert grace is None
def test_grace_stays_off_under_fips_even_with_the_default_on():
"""FIPS deployments get no scrypt fallback even though the grace window now defaults on."""
grace: Final = legacy_session_keys_from_master_key(
MASTER_KEY,
KEYS,
environ=_environ(MappingProxyType({FIPS_MODE_ENV_VAR: "true"})),
)
assert grace is None
def test_legacy_grace_is_on_by_default_without_the_variable():
legacy: Final = _legacy_session_keys(MASTER_KEY)
grace: Final = legacy_session_keys_from_master_key(MASTER_KEY, KEYS, environ=_environ(MappingProxyType({})))
assert grace == legacy
def test_legacy_scrypt_derivation_is_cached_per_process(monkeypatch):
"""Repeated legacy-format token attempts must not re-run the memory-hard scrypt KDF."""
calls: Final[list[int]] = []
real_scrypt = hashlib.scrypt
def counting_scrypt(password, **kwargs):
calls.append(1)
return real_scrypt(password, **kwargs)
monkeypatch.setattr(hashlib, "scrypt", counting_scrypt)
legacy_scrypt.cache_clear()
try:
first: Final = legacy_scrypt(MASTER_KEY, b"litellm-mcp-gateway:session-signing:")
second: Final = legacy_scrypt(MASTER_KEY, b"litellm-mcp-gateway:session-signing:")
finally:
legacy_scrypt.cache_clear()
assert first is not None
assert first == second
assert len(calls) == 1
def test_legacy_session_refresh_token_opens_during_grace():
legacy: Final = _legacy_session_keys(MASTER_KEY)
minted: Final = mint_session_refresh_token(PRINCIPAL, legacy, NOW)

View file

@ -2632,10 +2632,43 @@ async def test_claude_code_refresh_of_a_legacy_scrypt_token_is_renewed_during_th
@pytest.mark.asyncio
async def test_claude_code_refresh_of_a_legacy_scrypt_token_is_rejected_without_grace(monkeypatch):
"""Without the grace variable the same scrypt-minted token answers invalid_grant and
the minter never runs."""
async def test_claude_code_refresh_of_a_legacy_scrypt_token_is_renewed_by_default_without_the_grace_variable(
monkeypatch,
):
"""The grace window defaults on: with no ``LITELLM_MCP_LEGACY_KDF_GRACE`` in the
environment a scrypt-minted refresh token still renews, so operators do not lose
main-era tokens on upgrade day because they never heard of the variable."""
monkeypatch.delenv("LITELLM_MCP_LEGACY_KDF_GRACE", raising=False)
monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False)
minter: Final = _Minter()
renewed: Final = await refresh_proxy_credential(
refresh_token=_legacy_session_refresh(),
client_id="claude_code",
master_key=MASTER_KEY,
cache=DualCache(),
mint_proxy_credential=minter,
)
assert renewed.status_code == 200, renewed.body
def _hkdf_session_refresh(user_id="u1", client_id="claude_code", team_id="team-b"):
minted: Final = mint_session_refresh_token(
SessionPrincipal(user_id=user_id, client_id=client_id, audience="proxy_api", team_id=team_id),
session_keys_from_master_key(MASTER_KEY),
datetime.now(timezone.utc),
)
assert isinstance(minted, MintedSessionToken)
return minted.token.get_secret_value()
@pytest.mark.asyncio
async def test_claude_code_refresh_rejects_a_legacy_token_but_renews_an_hkdf_one_when_grace_is_explicitly_disabled(
monkeypatch,
):
"""``LITELLM_MCP_LEGACY_KDF_GRACE=false`` is the operator's opt-out: the scrypt token
answers invalid_grant without running the minter, while an HKDF control renews fine."""
monkeypatch.setenv("LITELLM_MCP_LEGACY_KDF_GRACE", "false")
monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False)
minter: Final = _Minter()
refused: Final = await refresh_proxy_credential(
refresh_token=_legacy_session_refresh(),
@ -2647,3 +2680,12 @@ async def test_claude_code_refresh_of_a_legacy_scrypt_token_is_rejected_without_
assert refused.status_code == 400
assert json.loads(refused.body)["error"] == "invalid_grant"
assert minter.calls == []
control: Final = await refresh_proxy_credential(
refresh_token=_hkdf_session_refresh(),
client_id="claude_code",
master_key=MASTER_KEY,
cache=DualCache(),
mint_proxy_credential=minter,
)
assert control.status_code == 200, control.body
assert json.loads(control.body)["access_token"] == "sk-cli-u1"