CreaRack-SL

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

  1. Nueva setting CREDENTIAL_ENCRYPTION_KEY (env var, vacía por defecto) en config/settings/base.py.

    • Cuando está vacía → comportamiento legacy (cifra con SECRET_KEY).
    • Cuando se activa → cifra/descifra con ella.
  2. 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.
  3. Command reencrypt_credentials — migra todos los ciphertexts guardados a la clave activa:

    • Recorre StoredCredential.encrypted_data y SignagePlayer.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.
  4. 5 tests nuevos verifican:

    • Roundtrip con clave dedicada.
    • Fallback a SECRET_KEY legacy 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_KEY ya 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 StoredCredential y SignagePlayer (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:

  1. test_active_key_roundtrip() — crypt/decrypt con clave dedicada.
  2. test_decrypt_falls_back_to_legacy_secret_key() — ciphertext antiguo sigue siendo legible.
  3. test_dev_key_rescue() — DEV_KEY rescue path (ciphertext viejo de desarrollo).
  4. test_reencrypt_command_migrates_to_active_key() — el command migra efectivamente.
  5. 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):

  1. Generar CREDENTIAL_ENCRYPTION_KEY — UUID o clave segura (>256 bits).
  2. Pegarla en Dokploy — panel de variables de STAGE.
  3. Migrar STAGE:
    • python manage.py reencrypt_credentials --dry-run (validar).
    • python manage.py reencrypt_credentials (aplicar).
  4. Validar en STAGE — todos los endpoints de credenciales funcionan.
  5. Repetir en PROD.
  6. PR posterior (fase 3):
    • Retira DEV_KEY de la cadena.
    • Retira fallback SECRET_KEY de la cadena de descifrado (solo mantiene la clave dedicada).
    • Los secretos ya están re-cifrados, así que no hay riesgo.

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_KEY hardcodeada 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_KEY está 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]]