Volver a la wiki

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:

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):

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:

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:


Verificación


Véase también

Subir