Volver a la wiki

Resumen

Servicio centralizado de cifrado y descifrado de credenciales en CreaRack. Utiliza Fernet (AES-128 + HMAC-SHA256 vía cryptography library) para cifrar credenciales en reposo en la BD. Soporta rotación de claves sin perder los datos existentes mediante una cadena de claves heredadas (legacy key chain).

Nueva en T1 (cifrado SNMP): dos métodos públicos encrypt_if_plain() e decrypt_or_passthrough() para soporte de plaintext legado e idempotencia.

Arquitectura

Gestión de claves

Las claves de cifrado residen en variables de entorno (Django settings):

VariableDescripción
CREDENTIALS_ENCRYPTION_KEYClave maestra actual (Fernet key base64)
CREDENTIALS_LEGACY_KEYSLista de claves antiguas (colon-separated)
DEV_KEYClave development (rescue, solo si no hay ENCRYPTION_KEY)

Durante descifrado, CredentialManager intenta todas las claves en orden: clave actual → legacy keys. El primer éxito devuelve el plaintext descifrado.

Durante cifrado, siempre usa la clave maestra actual (CREDENTIALS_ENCRYPTION_KEY).

Algoritmo

Métodos públicos

encrypt_credential(value: str) -> str

Cifra un string con la clave maestra actual.

from core.security import CredentialManager

encrypted = CredentialManager.encrypt_credential("s3cr3t")
# → "gAAAAA5Z_uq3Z4_eVk8..." (token Fernet)

safe_decrypt(value: str, field_name: str = "") -> str | None

Descifra un token Fernet. Si falla (token corrupto, clave no en cadena), retorna None (nunca filtra ciphertext).

plaintext = CredentialManager.safe_decrypt("gAAAAA...", "snmp_community")
# → "s3cr3t" o None si token indescifrable

is_encrypted(value: str) -> bool ✨ (existente)

Detecta si un string es un token Fernet (empieza con gAAAAA).

CredentialManager.is_encrypted("gAAAAA...")  # → True
CredentialManager.is_encrypted("public")     # → False

encrypt_if_plain(value: str) -> str ✨ NUEVO en T1

Cifra un valor a menos que:

CredentialManager.encrypt_if_plain("public")      # → "gAAAAA..."
CredentialManager.encrypt_if_plain("gAAAAA...")   # → "gAAAAA..." (sin cambio)
CredentialManager.encrypt_if_plain("")            # → ""
CredentialManager.encrypt_if_plain(None)          # → None

Uso: en save() de modelos, migraciones de datos, bulk paths. Permite re-ejecutar operaciones sin duplicar cifrado.

decrypt_or_passthrough(value: str, field_name: str = "") -> str ✨ NUEVO en T1

Descifra un token Fernet; tolera plaintext legado (pre-migración):

CredentialManager.decrypt_or_passthrough("gAAAAA...")        # → "s3cr3t" (descifra)
CredentialManager.decrypt_or_passthrough("public")           # → "public" (plaintext legado)
CredentialManager.decrypt_or_passthrough("gAAAAA_corrupto")  # → "" (token indescifrable)
CredentialManager.decrypt_or_passthrough("")                 # → ""

Uso: en puntos de lectura (pollers, API, deep-discover). Garantiza que SNMP libraries nunca reciban ciphertext Fernet.

Entidades que usan CredentialManager

EntidadCamposMétodo
network.DeviceProfilesnmp_community, snmp_v3_auth_key, snmp_v3_priv_key, ssh_password_encryptedsave() + encrypt_if_plain
monitoring.MonitoringTarget.configclaves sensibles (community, password, auth_key, etc.)save() + encrypt_config()
core.StoredCredential.encrypted_datadict con secrets (SSH/SNMP/HTTP)save()
signage.SignagePlayer.api_credentialsJSON con API key/token/passwordsave()
monitoring.NotificationChannel.configJSON con webhook URL/token/Authorizationsave()

Rotación de claves

Command reencrypt_credentials

Comando Django que re-cifra todas las credenciales con la clave maestra actual. Ruta: core/management/commands/reencrypt_credentials.py.

Flujo:

  1. Itera cada modelo (DeviceProfile, MonitoringTarget, StoredCredential, etc.).
  2. Para cada fila, descifra con la cadena completa de claves (intenta legacy keys si es necesario).
  3. Descifra → detecta cambio → re-cifra con clave maestra actual → guarda.
  4. Idempotente: filas ya cifradas con clave actual no se modifican.
  5. Soporta --dry-run para validar cambios sin guardar.

T1 Ampliación: ahora también procesa DeviceProfile y MonitoringTarget.config.

Invocación:

python manage.py reencrypt_credentials
python manage.py reencrypt_credentials --dry-run

Procedimiento de rotación (para ops)

  1. Generar nueva clave Fernet: from cryptography.fernet import Fernet; Fernet.generate_key()
  2. Actualizar CREDENTIALS_ENCRYPTION_KEY en settings (nueva clave).
  3. Mover clave anterior a CREDENTIALS_LEGACY_KEYS.
  4. Desplegar cambios (Dokploy).
  5. Ejecutar command: python manage.py reencrypt_credentials (sin --dry-run).
  6. Validar en logs: filas re-cifradas con nuevo key.
  7. Opcional: limpiar CREDENTIALS_LEGACY_KEYS después de que todos los datos usen nueva clave (mantener por ahora para seguridad).

Compatibilidad y tolerancia

Plaintext legado

Pre-T1, filas en DeviceProfile tenían credenciales en claro. Tras migración:

Token corrupto

Si un token Fernet se corrompe (almacenamiento defectuoso, truncado, etc.):

Idempotencia

encrypt_if_plain() permite operaciones idempotentes:

Seguridad

Ejemplos de uso

En un modelo save()

class DeviceProfile(models.Model):
    snmp_community = models.TextField(blank=True)
    
    def save(self, *args, **kwargs):
        from core.security import CredentialManager
        
        # Cifra si plaintext, ignora si ya token, ignora si vacío
        self.snmp_community = CredentialManager.encrypt_if_plain(self.snmp_community)
        super().save(*args, **kwargs)

En un poller (punto de uso)

def poll_snmp(target):
    from core.security import CredentialManager
    from monitoring.services.snmp_service import SNMPService
    
    # Descifra; tolera plaintext legado; nunca filtra ciphertext
    community = CredentialManager.decrypt_or_passthrough(
        target.config.get("snmp_community", "public"),
        "snmp_community"
    )
    snmp = SNMPService(target.ip_address, community=community)
    return snmp.get("1.3.6.1.2.1.1.1.0")

En una migración (cifrar datos existentes)

def encrypt_credentials(apps, schema_editor):
    from core.security import CredentialManager
    
    DeviceProfile = apps.get_model("network", "DeviceProfile")
    for profile in DeviceProfile.objects.all().iterator():
        # Idempotente: tokens no se re-cifran
        profile.snmp_community = CredentialManager.encrypt_if_plain(profile.snmp_community)
        if profile.snmp_community:  # Solo si cambió
            profile.save(update_fields=["snmp_community"])

Véase también

Subir