"""Cifrado simétrico de secretos que hay que guardar y volver a leer. Se usa hoy para la contraseña de la llave privada del CSD: el timbrado necesita abrir el ``.key`` sin que haya nadie tecleando, así que la contraseña tiene que estar en reposo. Un hash no sirve — habría que recuperar el valor original, no compararlo. **La clave maestra vive en el entorno** (``CSD_ENCRYPTION_KEY``), nunca en la base ni en el repositorio. Quien tenga a la vez la base de datos y esa variable puede descifrar los secretos: esa es la propiedad que da el diseño y conviene tenerla presente al decidir quién ve qué. Para generar una clave nueva:: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" """ from cryptography.fernet import Fernet, InvalidToken from core.config import settings class SecretsNotConfigured(Exception): """No hay clave maestra configurada, así que no se puede cifrar ni descifrar.""" class SecretDecryptionError(Exception): """El valor guardado no se pudo descifrar con la clave maestra actual.""" def _fernet() -> Fernet: clave = (settings.CSD_ENCRYPTION_KEY or "").strip() if not clave: raise SecretsNotConfigured( "Falta CSD_ENCRYPTION_KEY. Genérala con " '`python -c "from cryptography.fernet import Fernet; ' 'print(Fernet.generate_key().decode())"` y ponla en el .env.' ) try: return Fernet(clave.encode("utf-8")) except (ValueError, TypeError) as exc: raise SecretsNotConfigured( "CSD_ENCRYPTION_KEY no es una clave Fernet válida (32 bytes en base64 url-safe)." ) from exc def encrypt_secret(value: str) -> str: """Cifra un secreto. Devuelve el token en texto, listo para guardar en una columna.""" if not value: raise ValueError("no se cifra un secreto vacío") return _fernet().encrypt(value.encode("utf-8")).decode("ascii") def decrypt_secret(token: str) -> str: """Recupera el secreto original. Falla con ``SecretDecryptionError`` si la clave maestra cambió o el dato está corrupto. Se distingue de ``SecretsNotConfigured`` a propósito: una dice "configura la variable" y la otra "la variable no es la que cifró este dato", y confundirlas manda a buscar al lugar equivocado. """ if not token: raise SecretDecryptionError("no hay secreto guardado") try: return _fernet().decrypt(token.encode("ascii")).decode("utf-8") except InvalidToken as exc: raise SecretDecryptionError( "No pude descifrar el secreto guardado: la clave maestra no corresponde con la que " "se usó al guardarlo, o el dato está dañado. Hay que volver a capturarlo." ) from exc def secrets_available() -> bool: """``True`` si hay clave maestra utilizable. Para avisar en la UI antes de pedir el dato.""" try: _fernet() return True except SecretsNotConfigured: return False