Resumen
En la sesión 128 (2026-06-11) se cierra el fleco E del Plan Hardening (s101): retirada de la clave de desarrollo hardcodeada (DEV_KEY) y del fallback implícito a SECRET_KEY de la cadena de descifrado de CredentialManager.
Una clave fija en el código fuente, aunque de desarrollo, representaba un riesgo residual: un atacante con acceso al repositorio y a la base de datos de producción podía descifrar credenciales almacenadas. Este cambio cierra ese vector tras verificación operacional completa.
Contexto
Por qué mide
El Plan Hardening hito E (mayo 2026, s101) identificó que:
- Existía una clave de desarrollo hardcodeada (
DEV_KEY) en el código. - El sistema la probaba como último recurso al descifrar credenciales.
- Era la misma para cualquier instalación (pública de facto).
- Un atacante con acceso al repositorio + base de datos podría descifrar material de producción.
La solución arquitectónica: migrar a una cadena de descifrado explícita y ordenada:
- Clave activa (dedicada o
SECRET_KEYen dev). - Clave anterior durante rotación (
CREDENTIAL_ENCRYPTION_KEY_OLD). - Sin fallbacks implícitos ni claves públicas.
Decisión tomada
Cambio en core/security/credential_manager.py
Antes (fleco abierto):
_decryption_chain(include_dev_rescue=True):
# Intentaba: activa → SECRET_KEY → DEV_KEY
chain = [active_material, secret_key, DEV_KEY] # DEV_KEY hardcodeada
Después (fleco E cerrado):
_decryption_chain():
# Ahora: activa → CREDENTIAL_ENCRYPTION_KEY_OLD (explícita)
chain = [active_material, previous_old_key_or_none]
Cadena de descifrado: ¿qué cambió?
| Momento | Cadena | Estado |
|---|---|---|
| Pre-s101 | activa → SECRET_KEY (impl) → DEV_KEY (hardcoded) | ❌ Insegura (DEV_KEY pública) |
| s101–s127 | Mismo, pero plan abierto + reencrypt_credentials listo | ⚠️ Transitoria (re-cifrado pendiente) |
| s128 | activa → CREDENTIAL_ENCRYPTION_KEY_OLD (decl. explícita) | ✅ Segura (sin hardcodes) |
SECRET_KEY en dev/test: continúa siendo la clave activa en esos entornos (sin cambios en el comportamiento de dev).
Ejecución: orden seguro en producción
Antes de retirar los fallbacks, se ejecutó la migración de ciphertext:
Paso 1: Ampliar cobertura de reencrypt_credentials (PR #109)
- Incluye
Device.management_config(último almacén de credenciales pendiente).
Paso 2: Re-cifrado en PROD (verificado)
reencrypt_credentials --dry-run # 401 filas evaluadas
reencrypt_credentials # 264 campos reescritos a clave activa
# 0 irrecuperables
# Verificado y completado
Paso 3: Verificación STAGE
- Base de datos de testing confirmada vacía (sin credenciales legadas).
Paso 4: Retirada del código (este PR, s128)
DEV_KEYeliminada deCredentialManager.include_dev_rescueremovido de firmas públicas.- Tests reescritos al nuevo contrato.
Impacto en el código
Archivos modificados
-
core/security/credential_manager.py(core)- Retirada de
DEV_KEY(constante). - Firma de
_decryption_chain()sininclude_dev_rescue. - Firma de
decrypt_credential()sininclude_dev_rescue. - Documentación interna actualizada.
- Retirada de
-
core/management/commands/reencrypt_credentials.py- Llamadas a
decrypt_credential()sin parámetroinclude_dev_rescue. - Mensajes de error actualizados (referencia a
CREDENTIAL_ENCRYPTION_KEY_OLD).
- Llamadas a
-
Tests (reescritos al nuevo contrato)
tests/test_credential_key_rotation.py:test_secret_key_fallback_removed(): verifica que ciphertext legado no descifra sin_OLD.test_chain_is_only_active_plus_old(): verifica cadena exacta (sinDEV_KEY).
tests/api/test_config_ia_diferidos.py:test_device_management_config_reencrypted(): ajustado aCREDENTIAL_ENCRYPTION_KEY_OLD.
tests/api/test_t1_snmp_encryption.py:test_covers_device_profiles_and_targets(): idem.
Sin regresiones
Suite Docker ejecutada tras cambios: diff vs. base = 0 (todas las pruebas pasan).
Implicaciones de seguridad
Vector cerrado
- ❌ Clave fija pública en el código: ELIMINADA.
- ❌ Fallback implícito a
SECRET_KEY: ELIMINADO. - ✅ Fallbacks explícitos vía
CREDENTIAL_ENCRYPTION_KEY_OLD: SOPORTADO (solo durante rotación).
Almacenes de credenciales cubiertos
monitoring.MonitoringTarget.config(snmp_community / v3 keys).racks.Device.management_config(ip/username/password/enable_password).- Otros
StoredCredentialcon campos de sistema.
Checklist operacional (completado)
- ✅ Re-cifrado verificado en PROD (264 campos, 0 irrecuperables).
- ✅ STAGE confirmado vacío.
- ✅ Tests de rotación reescritos.
- ✅ Suite Docker limpia.
- ✅ Documentación (CHANGELOG + RELEASE_NOTES + s128).
- ✅ Cambios de código ejecutados.
Próximos pasos
La cadena de descifrado queda cerrada y explícita. Futuras rotaciones de claves:
- Establecer
CREDENTIAL_ENCRYPTION_KEY_OLD = <clave_anterior>. - Ejecutar
reencrypt_credentialspara migrar al activo nuevo. - Limpiar
CREDENTIAL_ENCRYPTION_KEY_OLD = "".
No hay fallbacks silenciosos que rescaten ciphertext antiguo.
Véase también
- [[entity—core—service—credential-manager]]
- [[entity—core—management-command—reencrypt-credentials]]
- [[concept—security—key-rotation]]
- [[concept—saas—multi-tenancy]]
- [[entity—core—model—organization]]