promptlooper/backend/encryption.py
John Lightner 35d72e7fa8 MAESTRO: Implement LLM endpoints router with CRUD, test_connection, and Fernet-encrypted API key storage
- 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
2026-04-07 03:13:52 -05:00

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