crowdata / app /utils /encryption.py
YOSOYYONOSOYOTRO's picture
Upload folder using huggingface_hub (part 2)
4223796 verified
Raw
History Blame Contribute Delete
5.43 kB
"""
MFA Encryption Utilities — CrowData.
Cifra/descifra secretos TOTP y códigos de respaldo usando Fernet (AES-128-GCM).
La clave se deriva de MFA_ENCRYPTION_KEY (base64, 32 bytes) en settings.
Soporte de versiones:
- v2: Fernet con MFA_ENCRYPTION_KEY (actual, recomendado)
- v1: PBKDF2 derivado de SECRET_KEY (legacy, solo lectura para migración)
Formato v1: `v1:<fernet_token>`
Formato v2: `v2:<fernet_token>`
"""
from cryptography.fernet import Fernet, InvalidToken
from app.config import get_settings
import base64
import json
import logging
logger = logging.getLogger(__name__)
_settings = None
# ─── v2: Fernet con MFA_ENCRYPTION_KEY ───
def _get_fernet_v2() -> Fernet:
global _settings
if _settings is None:
_settings = get_settings()
key = _settings.mfa_encryption_key
if not key:
raise RuntimeError("MFA_ENCRYPTION_KEY no configurado en .env")
try:
decoded = base64.urlsafe_b64decode(key)
if len(decoded) != 32:
raise ValueError
except Exception:
raise RuntimeError("MFA_ENCRYPTION_KEY debe ser base64 url-safe de 32 bytes (generar con: python -c \"import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())\")")
return Fernet(key.encode())
# ─── v1: PBKDF2 derivado de SECRET_KEY (legacy, solo lectura) ───
_v1_fernet = None
def _get_fernet_v1() -> Fernet:
global _v1_fernet
if _v1_fernet is None:
s = get_settings()
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=b'crowdata-mfa-encryption',
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(s.secret_key.encode()))
_v1_fernet = Fernet(key)
return _v1_fernet
# ─── API pública versionada ───
VERSION_PREFIX_V1 = b"v1:"
VERSION_PREFIX_V2 = b"v2:"
def _encrypt_with_version(data: str, version: int = 2) -> str:
"""Cifra con prefijo de versión."""
if version == 1:
f = _get_fernet_v1()
prefix = VERSION_PREFIX_V1
else:
f = _get_fernet_v2()
prefix = VERSION_PREFIX_V2
return (prefix + f.encrypt(data.encode())).decode()
def _decrypt_with_version(encrypted: str) -> str:
"""Descifra detectando automáticamente la versión por prefijo."""
if not encrypted:
raise ValueError("Empty encrypted data")
data_bytes = encrypted.encode()
if data_bytes.startswith(VERSION_PREFIX_V1):
f = _get_fernet_v1()
return f.decrypt(data_bytes[len(VERSION_PREFIX_V1):]).decode()
elif data_bytes.startswith(VERSION_PREFIX_V2):
f = _get_fernet_v2()
return f.decrypt(data_bytes[len(VERSION_PREFIX_V2):]).decode()
else:
# Sin prefijo = legacy sin versión, intentar v1 (formato anterior)
logger.warning("Encrypted data without version prefix, trying v1 (legacy)")
try:
return _get_fernet_v1().decrypt(data_bytes).decode()
except InvalidToken:
# Último intento con v2
return _get_fernet_v2().decrypt(data_bytes).decode()
# ─── API pública (usa v2 para escribir, ambas para leer) ───
def encrypt_mfa(data: str) -> str:
"""Cifra un string (secreto TOTP) usando v2."""
return _encrypt_with_version(data, version=2)
def decrypt_mfa(encrypted: str) -> str:
"""Descifra detectando versión automáticamente (v1 o v2)."""
return _decrypt_with_version(encrypted)
def encrypt_backup_codes(codes: list[str]) -> str:
"""Cifra una lista de códigos de respaldo como JSON usando v2."""
import json
return encrypt_mfa(json.dumps(codes))
def decrypt_backup_codes(encrypted: str) -> list[str]:
"""Descifra y parsea la lista de códigos de respaldo."""
import json
return json.loads(decrypt_mfa(encrypted))
# ─── Migración / compatibilidad ───
def is_encrypted(data: str) -> bool:
"""Detecta si un string parece estar cifrado (tiene prefijo v1: o v2: o parece Fernet)."""
try:
if data.startswith(("v1:", "v2:")):
return True
# Legacy sin prefijo
return len(data) > 40 and data.endswith("=")
except Exception:
return False
def safe_decrypt_mfa(data: str) -> str:
"""Descifra si está cifrado, sino retorna tal cual (para migración gradual)."""
if not data:
return data
if is_encrypted(data):
try:
return decrypt_mfa(data)
except Exception as e:
logger.warning(f"Failed to decrypt MFA data: {e}")
return data # Fallback
return data
def safe_decrypt_backup_codes(data: str) -> list[str]:
"""Descifra códigos de respaldo si están cifrados, sino parsea JSON o lista vacía."""
if not data:
return []
if is_encrypted(data):
try:
return decrypt_backup_codes(data)
except Exception:
pass
# Fallback: intentar parsear como JSON
import json
try:
return json.loads(data)
except Exception:
return []
def migrate_to_v2(encrypted_v1: str) -> str:
"""Re-cifra dato v1 a v2. Para migración batch."""
plaintext = _get_fernet_v1().decrypt(encrypted_v1.encode()).decode()
return encrypt_mfa(plaintext)