CreaRack-SL

El PIN del portal de clientes de Signage se guardaba en claro en la base de datos

Cuándo

Hallazgo signage #3/#14 de la Auditoría Suprema 2 (PR #501), dejado diferido como decisión de producto. Edu decidió el criterio el 04-09-2026 (task #288) y se cerró el 05-09-2026 con el commit 3eb4aae1 (PR #505, v1.113.0). No hubo explotación conocida ni incidente en producción: es un hallazgo de auditoría cerrado antes de que se materializara ningún daño.

Síntomas visibles

El PIN con el que un cliente entra a su portal (ClientProject.pin) se guardaba en texto plano en la base de datos — visible en cualquier dump de BD, backup de organización o acceso directo a la fila. El modelo nunca pasó por el cifrado que ya protegía las credenciales de dispositivos (SNMP/SSH de DeviceProfile).

Causa raíz

ClientProject.pin se diseñó como CharField(20) en claro desde el origen del modelo. A diferencia de las credenciales de red, nadie lo hizo pasar por CredentialManager — el campo quedó fuera del criterio de cifrado en reposo que sí se aplicó a otras credenciales del proyecto.

Fix aplicado

Commit 3eb4aae1 (PR #505):

  • signage/models/deployment.py — pin pasa de CharField(20) a TextField (el token Fernet no cabe en 20 caracteres). save() aplica CredentialManager.encrypt_if_plain (idempotente); el accessor pin_plain descifra tolerando filas heredadas aún sin cifrar.
  • Migración signage/0018_encrypt_clientproject_pin — cambia la columna y cifra las filas existentes. La marcha atrás es un no-op a propósito: descifrar en el rollback re-expondría los PIN.
  • signage/views.py::client_portal_view — el portal compara contra pin_plain, en tiempo constante (hmac.compare_digest); el texto cifrado no vale como PIN.
  • signage/api/client_projects.py — el PIN en claro solo viaja a quien puede editar signage (has_permission(..., "signage", "edit")); al resto se le devuelve la máscara •••• + el campo has_pin. Un PATCH que reciba la máscara conserva el PIN existente. Tope de 20 caracteres en la entrada.
  • racks/api/export/backup_domains.py — el backup de organización exporta pin_plain (en claro), con el mismo criterio que las credenciales SNMP de los targets: el ZIP se restaura en otra instalación o con otra clave, y el save() del restore vuelve a cifrarlo. Este ajuste llegó como segundo commit del mismo PR porque el CI rojo lo cazó en la primera pasada — el test de ida y vuelta de test_backup_scope.py comprobaba el campo en claro contra un modelo que ya no lo devolvía así.
  • 12 tests nuevos en tests/signage/test_client_project_pin_encrypted.py.

Lecciones

  • Cifrado reversible, no hash — a propósito. Un PIN de portal no es una contraseña de login: quien gestiona el proyecto tiene que poder leerlo para dárselo al cliente. Hashearlo habría roto ese flujo; cifrarlo con CredentialManager (el mismo mecanismo que las credenciales SNMP/SSH) da confidencialidad en reposo sin perder la capacidad de mostrarlo a quien tiene permiso.
  • Quién puede VER el PIN sigue el mismo criterio que quién puede FIJARLO. Restringir la lectura solo a admin habría dejado al operador que creó el PIN sin poder comunicárselo al cliente.
  • Un campo sensible nuevo que copia el patrón de otro (backup, export) hereda también su exposición — el fix de backup_domains.py no se pensó en el primer commit y lo atrapó el CI, no una revisión manual.

Preventivos futuros

  • Al añadir un campo que guarda un secreto reutilizable por un humano (PIN, token, contraseña), pasar por CredentialManager desde el diseño inicial del modelo, no como parche posterior tras auditoría.
  • Límite honesto documentado en el propio CHANGELOG: encrypt_if_plain decide “ya cifrado” por el prefijo gAAAAA de Fernet; un PIN en claro que empezase por esas seis letras se guardaría tal cual sin cifrar. Se asume porque el PIN está limitado a 20 caracteres y en la práctica es numérico.

Véase también

  • [[crearack—signage—client-portal]]
  • [[decision—20260609—signage-broken-access-control]]
  • [[incident—20260609—audit-suprema-signage]]
  • [[incident—20260823—signage-publisher-cross-tenant-playlist-leak]]
  • [[incident—20260820—restore-roba-medios-signage-org-origen]]
  • [[feature—backup-restore—extended-domains-v1-70-0]]
  • [[entity—signage—model—signageplayer]]