CreaRack-SL

ADR: Hito E Fase 1 — Cifrado de credenciales desacoplado de SECRET_KEY (s101)

Estado

Aceptada (PR #57, merge 2026-06-01, Sesión 101)


Contexto

El problema

Históricamente, las credenciales de dispositivos de red (SNMP v2, SSH, HTTP basic auth) se almacenaban cifradas con Fernet, pero la clave de cifrado derivaba de SECRET_KEY (la clave maestra de Django). Esto creaba un acoplamiento peligroso:

Si rotábamos SECRET_KEY por seguridad, todas las credenciales almacenadas se volvían ilegibles.

Ej:

  • Día 1: Cifras ssh_password="cisco123" con SECRET_KEY=old.
  • Día 30: Rotas SECRET_KEY→new (mejor práctica de seguridad).
  • Día 31: Intentas desencriptar → falla porque la clave vieja se perdió.

Además:

  • El fallback a DEV_KEY (clave de desarrollo hardcodeada en el código) era un vector de seguridad.
  • No había forma clara de migrar credenciales a una nueva clave sin perderlas.

Decisión

Desacoplar la clave de cifrado de credenciales (CREDENTIAL_ENCRYPTION_KEY) de SECRET_KEY en dos fases:

Fase 1 (CÓDIGO, PR #57, backward-compatible)

  1. Nueva setting CREDENTIAL_ENCRYPTION_KEY en config/settings/base.py:

    • Env var, vacía por defecto.
    • Si está vacía → cifra/descifra con SECRET_KEY (comportamiento legacy, nada cambia).
    • Si está configurada → cifra/descifra con ella.
  2. Cadena ordenada de desencriptación en CredentialManager:

    • Cifra siempre con la clave activa (dedicada si existe, si no SECRET_KEY).
    • Descifra probando en orden: activa → SECRET_KEY (legacy) → DEV_KEY (rescate).
    • Permite que secretos antiguos sigan siendo legibles incluso tras activar la clave nueva.
  3. Management command reencrypt_credentials:

    • Migra ciphertext almacenado de credenciales a la clave activa.
    • Idempotente, soporta --dry-run.
    • Rescata credenciales cifradas con cualquier clave histórica (incluso DEV_KEY).
  4. 5 tests nuevos verifican rotaciones, rescates, idempotencia.

Fase 2 (OPERATIVA, sesión posterior)

  1. Generar CREDENTIAL_ENCRYPTION_KEY (UUID, >256 bits).
  2. Activarla en Dokploy STAGE.
  3. Ejecutar reencrypt_credentials --dry-run → validar.
  4. Ejecutar reencrypt_credentials → migrar ciphertext.
  5. Validar app en STAGE (GET/POST/PUT credenciales).
  6. Repetir en PROD.

Fase 3 (CÓDIGO, PR posterior)

Retira caducados de la cadena de descifrado (ya no son necesarios porque PROD está re-cifrado):

  • Retira DEV_KEY de la cadena.
  • Retira fallback SECRET_KEY.
  • Solo mantiene clave dedicada.

Rationale

¿Por qué dos fases?

  1. Fase 1 es código, cero impacto: si CREDENTIAL_ENCRYPTION_KEY no se configura, nada cambia. Deploy seguro.
  2. Fase 2 es operativa, controlada: en STAGE primero, validar, luego PROD.
  3. Fase 3 es housekeeping: retira legado una vez PROD esté migrado.

→ Risk minimization: cada fase es reversible, validable antes de la siguiente.

¿Por qué una cadena de desencriptación?

Durante la rotación, el estado es transitorio:

  • Algunas credenciales ya cifradas con la clave nueva.
  • Otras aún con la clave vieja (si hubo un error).
  • Historicamente, quizá algunas con DEV_KEY (de dev→prod).

Sin cadena: descifrado falla para las viejas.
Con cadena: todas funcionan, y reencrypt_credentials las migra gradualmente.

→ Zero downtime migration.

¿Por qué no retira DEV_KEY en Fase 1?

Porque:

  1. Es una clave conocida (está en el código fuente), no un secreto.
  2. Solo se usa en descifrado (rescate), nunca para cifrar nada nuevo.
  3. Puede haber credenciales históricas cifradas con ella (de dev→prod migrations).
  4. Retenerla en Fase 1 hace el cambio 100% backward-compatible.
  5. Una vez PROD está re-cifrado (Fase 2), Fase 3 la retira sin impacto.

→ Belt and suspenders: máxima compatibilidad + clear exit strategy.

¿Por qué idempotencia en el command?

Si ejecutas reencrypt_credentials dos veces:

  • Primera: migra 1000 campos.
  • Segunda: rescatea, ve que ya están con la clave activa, no reescribe.

→ Operación segura para reintentos, scripts cronjob, etc.


Alternativas consideradas

Opción 1: Retira DEV_KEY inmediatamente (Fase 1 = Fase 3)

Rechazo: ruptura de credenciales históricas si alguna está con DEV_KEY. Riesgo alto.

Opción 2: Mantiene fallback SECRET_KEY sin cadena

Rechazo: vuelve al problema original (rotar SECRET_KEY rompe credenciales). No resuelve el GAP.

Opción 3: Clave dedicada + migración batch en hora de mantenimiento

Rechazo: requiere downtime. La cadena permite zero downtime.

Opción 4: Diferentes claves para StoredCredential vs SignagePlayer

Rechazo: complejidad. Ambos son credenciales, merecen el mismo tratamiento. Una cadena es más simple.


Implicaciones

Seguridad

  • ✅ Credenciales desacopladas de la clave general (rotación de SECRET_KEY no las toca).
  • ✅ DEV_KEY no se usa para cifrar nada nuevo (Fase 1).
  • ✅ DEV_KEY se retira completamente en Fase 3.
  • ✅ Fernet sigue siendo el estándar (AES-128 CBC, timestamp, autenticación).

Performance

  • ✅ Cadena de claves no es lenta (máx 3 iteraciones en descifrado).
  • ✅ Encriptación es O(1) — siempre usa la clave activa.
  • ✅ Command reencrypt_credentials es O(N), pero .iterator() no carga todo en RAM.

Operacional

  • ✅ Setting env var estándar (no precisa cambios en deploy infraestructura).
  • ✅ Command es dry-run-able y reporta claramente.
  • ✅ Sin cambios en API pública (15 call sites no se tocan).

User-facing

  • ✅ Ningún impacto hasta Fase 2 (Fase 1 es backward-compatible).
  • ✅ Fase 2 es transparente (mismo plaintext, diferente clave interna).

Validación (s101)

  • ✅ 18 tests verdes (5 nuevos + 13 existentes).
  • ✅ Rotación simulada (decrypt con legacy, re-encrypt con activa).
  • ✅ Rescate de DEV_KEY validado.
  • ✅ Idempotencia del command validada.
  • ✅ API pública intacta.

Follow-up

  • [Fase 2 AD]: “Operativa: generar CREDENTIAL_ENCRYPTION_KEY, activar en STAGE, migrar, validar, repetir PROD”.
  • [Fase 3 PR]: “Retira DEV_KEY + fallback SECRET_KEY una vez PROD re-cifrado”.

Notas

  • GAP #2 del Plan Hardening post-Máster (lista en TASK.md).
  • Hito E (5º hito de severidad ALTA).
  • Coordinado con Edu (con Claude Code Opus).
  • Verificación local: credenciales reales (SNMP v2, SSH) re-cifradas OK en dev.

Véase también

  • [[feature—security—credential-encryption-dedicated-key]]
  • [[entity—core—service—credential-manager]]
  • [[entity—core—command—reencrypt-credentials]]
  • [[concept—saas—multi-tenancy]]
  • [[concept—security—encryption]]