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):
| Variable | Descripción |
|---|---|
CREDENTIALS_ENCRYPTION_KEY | Clave maestra actual (Fernet key base64) |
CREDENTIALS_LEGACY_KEYS | Lista de claves antiguas (colon-separated) |
DEV_KEY | Clave 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
- Cifrado:
Fernet.encrypt(plaintext.encode()) → "gAAAAA..." - Descifrado:
Fernet.decrypt(ciphertext) → plaintext(intenta cadena de claves) - Formato: Fernet (RFC 7539) — ciphertext base64, incluye timestamp + HMAC.
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:
- Sea falsy (
None,"", etc.) — devuelve tal cual. - Ya sea un token Fernet — devuelve tal cual (idempotente).
- En otro caso — cifra.
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):
- Falsy o no-string — devuelve tal cual.
- No es token (no empieza
gAAAAA) — devuelve tal cual (plaintext legado). - Es token pero indescifrable — devuelve
""(nunca filtra ciphertext a bibliotecas SNMP o APIs).
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
| Entidad | Campos | Método |
|---|---|---|
network.DeviceProfile | snmp_community, snmp_v3_auth_key, snmp_v3_priv_key, ssh_password_encrypted | save() + encrypt_if_plain |
monitoring.MonitoringTarget.config | claves sensibles (community, password, auth_key, etc.) | save() + encrypt_config() |
core.StoredCredential.encrypted_data | dict con secrets (SSH/SNMP/HTTP) | save() |
signage.SignagePlayer.api_credentials | JSON con API key/token/password | save() |
monitoring.NotificationChannel.config | JSON con webhook URL/token/Authorization | save() |
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:
- Itera cada modelo (
DeviceProfile,MonitoringTarget,StoredCredential, etc.). - Para cada fila, descifra con la cadena completa de claves (intenta legacy keys si es necesario).
- Descifra → detecta cambio → re-cifra con clave maestra actual → guarda.
- Idempotente: filas ya cifradas con clave actual no se modifican.
- Soporta
--dry-runpara 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)
- Generar nueva clave Fernet:
from cryptography.fernet import Fernet; Fernet.generate_key() - Actualizar
CREDENTIALS_ENCRYPTION_KEYen settings (nueva clave). - Mover clave anterior a
CREDENTIALS_LEGACY_KEYS. - Desplegar cambios (Dokploy).
- Ejecutar command:
python manage.py reencrypt_credentials(sin--dry-run). - Validar en logs: filas re-cifradas con nuevo key.
- Opcional: limpiar
CREDENTIALS_LEGACY_KEYSdespué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:
DeviceProfile.snmp_communityahora esTextField(noCharField) y se cifra ensave().- Filas que aún tengan plaintext (de antes de la migración) no fallan:
decrypt_or_passthroughdevuelve tal cual. - API y pollers usan
decrypt_or_passthrough, toleran ambos formatos.
Token corrupto
Si un token Fernet se corrompe (almacenamiento defectuoso, truncado, etc.):
safe_decrypt()retornaNone.decrypt_or_passthrough()retorna"".- Nunca filtra ciphertext: SNMP libraries nunca ven un token
gAAAAA.
Idempotencia
encrypt_if_plain() permite operaciones idempotentes:
- Re-ejecutar migración: tokens ya cifrados no se duplican.
- Re-ejecutar
reencrypt_credentials: ningún cambio si clave sin cambios. - Bulk
save()de N filas: cada una cifra solo si plaintext.
Seguridad
- Algoritmo: Fernet (AES-128-CBC + HMAC-SHA256 con IV aleatorio).
- Entropía: Fernet genera key a partir de
Fernet.generate_key()(32 bytes aleatorios, base64). - Timestamp: Fernet incluye timestamp en ciphertext (protege contra replay).
- HMAC: verifica integridad (rechaza ciphertext modificado).
- Legacy chain: permite rotación sin downtime; claves antiguas solo para lectura.
- Nunca plaintext en logs:
safe_decrypt(..., field_name)solo loga nombre de campo, no valor.
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
- [[entity—network—model—device-profile]] — consume CredentialManager para SNMP
- [[entity—monitoring—model—monitoring-target]] — consume para config
- [[feature—security—t1-cifrado-snmp-reposo]] — caso de uso: T1 SNMP
- [[concept—security—credential-encryption]] — patrón de cifrado
- [[runbook—security—rotate-credential-key]] — procedimiento de rotación