- Add LLMEndpoint model to models.py with encrypted api_key field - Create encryption.py with Fernet symmetric encryption (key derived from JWT_SECRET via PBKDF2) - Implement full endpoints router: list, get, create, update, delete + test_connection - Test endpoint calls adapter.test_connection() and list_models() - API keys never exposed in responses; has_api_key boolean flag added - 25 tests in test_endpoints.py, all 444 tests passing
44 lines
1.4 KiB
Python
44 lines
1.4 KiB
Python
"""Fernet symmetric encryption for sensitive fields (API keys).
|
|
|
|
The encryption key is derived from JWT_SECRET using PBKDF2-HMAC-SHA256,
|
|
ensuring a stable 32-byte key suitable for Fernet.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
|
|
from config import settings
|
|
|
|
|
|
def _derive_fernet_key(secret: str) -> bytes:
|
|
"""Derive a Fernet-compatible key from an arbitrary string secret."""
|
|
# PBKDF2 with a fixed salt — the secret itself provides entropy.
|
|
# The salt is fixed so the same secret always yields the same key.
|
|
dk = hashlib.pbkdf2_hmac(
|
|
"sha256",
|
|
secret.encode("utf-8"),
|
|
b"promptlooper-fernet-salt",
|
|
iterations=100_000,
|
|
dklen=32,
|
|
)
|
|
return base64.urlsafe_b64encode(dk)
|
|
|
|
|
|
def get_fernet() -> Fernet:
|
|
"""Return a Fernet instance keyed from the current JWT_SECRET."""
|
|
return Fernet(_derive_fernet_key(settings.jwt_secret))
|
|
|
|
|
|
def encrypt_api_key(plain_key: str) -> str:
|
|
"""Encrypt an API key and return the ciphertext as a UTF-8 string."""
|
|
return get_fernet().encrypt(plain_key.encode("utf-8")).decode("utf-8")
|
|
|
|
|
|
def decrypt_api_key(encrypted_key: str) -> str:
|
|
"""Decrypt an API key. Raises ValueError on failure."""
|
|
try:
|
|
return get_fernet().decrypt(encrypted_key.encode("utf-8")).decode("utf-8")
|
|
except InvalidToken as exc:
|
|
raise ValueError("Failed to decrypt API key — JWT_SECRET may have changed") from exc
|