mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(mcp): derive bridge envelope keys with memory-hard scrypt, cached per master key
This commit is contained in:
parent
08963b744f
commit
ef9637035b
2 changed files with 48 additions and 17 deletions
|
|
@ -12,6 +12,7 @@ token-endpoint and admission wiring live in their respective call sites.
|
|||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr
|
||||
|
|
@ -28,30 +29,51 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
|
|||
open_envelope,
|
||||
)
|
||||
|
||||
_SIGNING_KEY_DOMAIN = "litellm-mcp-bridge:envelope-signing:"
|
||||
_ENCRYPTION_KEY_DOMAIN = "litellm-mcp-bridge:envelope-encryption:"
|
||||
_SIGNING_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-signing:"
|
||||
_ENCRYPTION_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-encryption:"
|
||||
_BEARER_PREFIX = "bearer "
|
||||
|
||||
# scrypt work factors (RFC 7914). n=2**15 with r=8/p=1 costs ~50ms and ~32MB per derivation, which
|
||||
# makes offline guessing of a candidate master key memory-hard rather than a bare hash comparison.
|
||||
_SCRYPT_N = 2**15
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
_SCRYPT_MAXMEM = 128 * _SCRYPT_N * _SCRYPT_R * 2
|
||||
_DERIVED_KEY_BYTES = 32
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def envelope_keys_from_master_key(master_key: str) -> EnvelopeKeys:
|
||||
"""Derive the envelope signing and encryption keys from the proxy master key.
|
||||
|
||||
Keyed HMAC-SHA256 over two distinct domain labels yields two independent 64-char
|
||||
(256-bit) subkeys from the one secret, so the producer (mint) and consumer (open) agree
|
||||
on keys without persisting any, and even a short master key still produces a >= 32-byte
|
||||
signing key (HS256's requirement). HMAC keys the derivation on ``master_key`` (the
|
||||
standard subkey-from-key construction) rather than hashing a concatenation. The
|
||||
derivation is deterministic; rotating ``master_key`` invalidates every outstanding
|
||||
envelope, which is the intended behavior for a signing-key change.
|
||||
|
||||
``master_key`` is the proxy's root signing secret and must be high-entropy: a captured
|
||||
envelope is an offline oracle for it, exactly as the existing master-key-signed session
|
||||
tokens already are, so a low-entropy master key compromises the proxy regardless of this
|
||||
path. A password-style slow KDF is deliberately not used here; it is the wrong tradeoff
|
||||
for a per-request high-entropy secret.
|
||||
A memory-hard scrypt KDF (RFC 7914) over two distinct domain-label salts yields two
|
||||
independent 256-bit subkeys from the one secret, so the producer (mint) and consumer
|
||||
(open) agree on keys without persisting any. scrypt is used rather than a bare hash or
|
||||
HMAC so that a captured envelope is not a cheap offline oracle for the master key: each
|
||||
candidate guess costs a full memory-hard derivation, which is what protects a deployment
|
||||
whose master key is weaker than it should be. The result is cached (the master key is
|
||||
fixed for a process), so the KDF runs once per key and adds nothing to the per-request
|
||||
admission path. The derivation is deterministic; rotating ``master_key`` invalidates
|
||||
every outstanding envelope, which is the intended behavior for a signing-key change.
|
||||
"""
|
||||
signing = hmac.new(master_key.encode(), _SIGNING_KEY_DOMAIN.encode(), hashlib.sha256).hexdigest()
|
||||
encryption = hmac.new(master_key.encode(), _ENCRYPTION_KEY_DOMAIN.encode(), hashlib.sha256).hexdigest()
|
||||
signing = hashlib.scrypt(
|
||||
master_key.encode(),
|
||||
salt=_SIGNING_KEY_DOMAIN,
|
||||
n=_SCRYPT_N,
|
||||
r=_SCRYPT_R,
|
||||
p=_SCRYPT_P,
|
||||
maxmem=_SCRYPT_MAXMEM,
|
||||
dklen=_DERIVED_KEY_BYTES,
|
||||
).hex()
|
||||
encryption = hashlib.scrypt(
|
||||
master_key.encode(),
|
||||
salt=_ENCRYPTION_KEY_DOMAIN,
|
||||
n=_SCRYPT_N,
|
||||
r=_SCRYPT_R,
|
||||
p=_SCRYPT_P,
|
||||
maxmem=_SCRYPT_MAXMEM,
|
||||
dklen=_DERIVED_KEY_BYTES,
|
||||
).hex()
|
||||
return EnvelopeKeys(signing_key=SecretStr(signing), encryption_key=SecretStr(encryption))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,15 @@ def test_key_derivation_signing_key_meets_hs256_floor_for_short_master_key():
|
|||
assert len(keys.signing_key.get_secret_value()) >= 32
|
||||
|
||||
|
||||
def test_key_derivation_is_cached_so_the_memory_hard_kdf_runs_once_per_key():
|
||||
"""The scrypt KDF is intentionally expensive to resist offline guessing, so it must be cached:
|
||||
repeated calls for the same master key return the identical object rather than re-deriving,
|
||||
keeping the per-request admission path free. Returning a distinct object each call would mean
|
||||
the cache was dropped and every open would pay the memory-hard cost."""
|
||||
first = envelope_keys_from_master_key("sk-cache-probe-key-9988776655")
|
||||
assert envelope_keys_from_master_key("sk-cache-probe-key-9988776655") is first
|
||||
|
||||
|
||||
def test_derived_keys_round_trip_mint_and_open():
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
result = resolve_bridge_envelope(_sealed_token(keys), keys, _NOW, _SERVER_ID)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue