Cifrado de credenciales con clave dedicada (Hito E Fase 1)
Resumen
Desacoplamiento de la clave de cifrado de credenciales (CREDENTIAL_ENCRYPTION_KEY) de la clave general de Django (SECRET_KEY). Esto elimina el riesgo de que una rotación de SECRET_KEY inutilice todas las contraseñas de red almacenadas (SNMP, SSH, HTTP).
Fase 1 (backward-compatible): introduce la infraestructura de claves sin cambiar PROD. Fase 2 (operativa) activará la clave nueva y re-cifrará el ciphertext existente.
Contexto
El problema
Hasta ahora, las credenciales de dispositivos de red (secretos SNMP v2, SSH, HTTP basic auth) se guardaban cifradas con Fernet, pero la clave de cifrado se derivaba directamente de SECRET_KEY. Esto significaba:
- Si algún día rotábamos
SECRET_KEY(por seguridad), todas las credenciales almacenadas se volvían ilegibles. - El fallback legacy a
DEV_KEY(clave de desarrollo hardcodeada) era un vector de seguridad.
La solución
-
Nueva setting
CREDENTIAL_ENCRYPTION_KEY(env var, vacía por defecto) enconfig/settings/base.py.- Cuando está vacía → comportamiento legacy (cifra con
SECRET_KEY). - Cuando se activa → cifra/descifra con ella.
- Cuando está vacía → comportamiento legacy (cifra con
-
Cadena de desencriptación ordenada en
CredentialManager:- Cifra siempre con la clave activa (dedicada si existe, si no
SECRET_KEY). - Descifra probando una cadena: activa →
SECRET_KEY→DEV_KEY(rescate). - Permite que secretos antiguos sigan siendo legibles durante y después de una rotación.
- Cifra siempre con la clave activa (dedicada si existe, si no
-
Command
reencrypt_credentials— migra todos los ciphertexts guardados a la clave activa:- Recorre
StoredCredential.encrypted_dataySignagePlayer.api_credentials. - Descifra con la cadena completa (rescata incluso con
DEV_KEY). - Re-cifra con la clave activa.
- Idempotente; reporta campos irrecuperables.
- Soporta
--dry-run.
- Recorre
-
5 tests nuevos verifican:
- Roundtrip con clave dedicada.
- Fallback a
SECRET_KEYlegacy cuando no hay clave dedicada. - Rescate de
DEV_KEY. - Migración del command.
- Idempotencia del command.
Cambios en el código
config/settings/base.py
CREDENTIAL_ENCRYPTION_KEY = os.getenv("CREDENTIAL_ENCRYPTION_KEY", "")
# Vacía → fallback a SECRET_KEY (legacy). Al activar + run reencrypt_credentials,
# migra ciphertext a la clave dedicada.
core/security/credential_manager.py
Refactor mayor (217 líneas → 190 líneas, más claras):
- Nuevo método
_active_key_material()— resuelve qué clave usar para cifrar. - Nuevo método
_decryption_chain()— genera cadena ordenada de materiales para descifrado. encrypt_credential()— ahora usa_active_key_material().decrypt_credential()— prueba la cadena en orden, loguea si no es la clave activa.- Métodos dict/helper (
encrypt_dict,decrypt_dict,safe_decrypt,mask_credential,is_encrypted) siguen igual (API pública intacta). - 15 call sites no se tocan — refactor fue interno.
DEV_KEYya no se usa para cifrar; solo queda como rescate en descifrado.
core/management/commands/reencrypt_credentials.py [NUEVO]
Command de Django con 115 líneas:
python manage.py reencrypt_credentials --dry-run # reporte sin escribir
python manage.py reencrypt_credentials # migra efectivamente
Lógica:
- Itera
StoredCredentialySignagePlayer(modelos con credenciales). - Por cada campo encrypted en
encrypted_data/api_credentials, descifra y re-cifra. - Reporta: filas escaneadas, campos cifrados, re-cifrados, irrecuperables.
- Si encuentra ciphertext que no se puede descifrar con NINGUNA clave de la cadena, lo loguea pero sigue (no aborta).
tests/test_credential_key_rotation.py [NUEVO]
5 tests con 100 líneas:
test_active_key_roundtrip()— crypt/decrypt con clave dedicada.test_decrypt_falls_back_to_legacy_secret_key()— ciphertext antiguo sigue siendo legible.test_dev_key_rescue()—DEV_KEYrescue path (ciphertext viejo de desarrollo).test_reencrypt_command_migrates_to_active_key()— el command migra efectivamente.test_reencrypt_command_is_idempotent()— re-ejecutar no cambia nada.
Todos usan @pytest.mark.django_db y override_settings para simular escenarios.
Roadmap: Fase 2 (Operativa)
⏳ Por hacer (sesión posterior con Edu):
- Generar
CREDENTIAL_ENCRYPTION_KEY— UUID o clave segura (>256 bits). - Pegarla en Dokploy — panel de variables de STAGE.
- Migrar STAGE:
python manage.py reencrypt_credentials --dry-run(validar).python manage.py reencrypt_credentials(aplicar).
- Validar en STAGE — todos los endpoints de credenciales funcionan.
- Repetir en PROD.
- PR posterior (fase 3):
- Retira
DEV_KEYde la cadena. - Retira fallback
SECRET_KEYde la cadena de descifrado (solo mantiene la clave dedicada). - Los secretos ya están re-cifrados, así que no hay riesgo.
- Retira
Impacto al usuario
Ahora (Fase 1): ninguno. Todo sigue igual que antes.
Tras Fase 2: ninguno visible — el cambio es transparente. Pero:
- Las credenciales de red son más resilientes a rotaciones de clave general.
- La
DEV_KEYhardcodeada desaparece de la cadena (reducción de surface de ataque).
Verificación
- 18 tests verdes (5 nuevos + 13 de credenciales existentes).
- API pública intacta (15 call sites).
- Backward-compatible: si
CREDENTIAL_ENCRYPTION_KEYestá vacía, funciona igual que antes. - Command idempotente y reporta claramente.
Véase también
- [[entity—core—service—credential-manager]]
- [[entity—core—command—reencrypt-credentials]]
- [[decision—20260601—hito-e-fase-1-credential-encryption]]
- [[concept—saas—multi-tenancy]]
- [[concept—security—encryption]]