El incidente con el que cerramos el módulo 5 lo dijo todo: la clave privada TLS de MediNube no cayó por un fallo matemático, cayó por un git push. Toda la criptografía que hemos construido a lo largo del curso —AEAD, Argon2id, firmas Ed25519, mTLS— comparte un único punto de fallo: las claves. Si una clave vive en el sitio equivocado, da igual lo perfecta que sea la primitiva que la usa. Esta lección responde a la pregunta que llevamos aplazando desde el módulo 1: ¿dónde deberían vivir los secretos de MediNube? Y de paso salda varias deudas pendientes: el diccionario CLAVES_API de 01-04, la clave maestra de historiales de 02-03, el pepper de 03-03 y el envelope encryption que anticipamos en 04-05.

Contenido

  1. El problema real: los secretos se filtran por descuido
  2. Inventario de secretos de MediNube
  3. Jerarquía de claves: envelope encryption con DEK y KEK
  4. La escala de madurez de la gestión de secretos
  5. Variables de entorno: uso correcto y límites
  6. Ficheros cifrados en el repositorio: sops y age
  7. Gestores de secretos: Vault y KMS en la práctica
  8. Rotación de la clave maestra: la criptoagilidad se cobra
  9. El pepper, por fin bien hecho
  10. Prevención: que el secreto no llegue al repositorio

El problema real: los secretos se filtran por descuido

Ningún atacante realista va a factorizar el módulo RSA-3072 de MediNube. Lo que sí hará MalloryClinic es buscar en los sitios donde los desarrolladores dejamos secretos sin querer:

  • Repositorios git: el privkey.pem del incidente de 05-03. El commit se borró, pero el historial de git y los bots que escanean GitHub no perdonan (un secreto subido a un repo público se considera comprometido en minutos).
  • Logs: un logger.debug(f"Conectando con clave {api_key}") escrito durante una depuración y olvidado.
  • Ficheros .env compartidos: por correo, por Slack, "solo esta vez para que puedas arrancar el proyecto".
  • Imágenes Docker: un COPY .env /app/ congela el secreto en cada capa de la imagen, aunque luego se borre el fichero.
  • Código fuente directamente: el clásico. Nuestro viejo conocido de 01-04:
# medinube/config.py — la deuda original del curso (01-04)
CLAVES_API = {
    "clinica-sol": "sk_live_9f8a7b6c5d4e3f2a",
    "centro-medico-luna": "sk_live_1a2b3c4d5e6f7a8b",
}

Este diccionario incumple la regla de oro 2 (el enemigo conoce el sistema: cualquiera con acceso al repo conoce las claves) y hace imposible la rotación sin desplegar código nuevo. Hoy lo jubilamos.

Inventario de secretos de MediNube

Antes de decidir dónde guardar cada secreto hay que saber cuáles tenemos. Este es el inventario acumulado durante el curso — hacer esta tabla en tu propio proyecto es el primer paso real de cualquier plan de gestión de secretos:

Secreto Origen en el curso Quién lo necesita Impacto si se filtra
Claves de API de las clínicas 01-04 (CLAVES_API) La app al autenticar peticiones Suplantar a Clínica Sol o Centro Médico Luna
Clave maestra de historiales 02-03 / 02-04 (AEAD v1 + HKDF) Servicio de historiales Descifrar todos los historiales, incluido el de Ana Pérez
Claves de webhooks (una por clínica) 03-02 (X-MediNube-Firma) Emisor de webhooks Falsificar notificaciones a las clínicas
Pepper de contraseñas 03-03 (aplazado hasta hoy) medinube/auth.py Anula la defensa extra frente a volcados de BD
Clave privada de firma de recetas 04-03 (Ed25519, Dra. Ferrer CS-4471) Servicio de recetas Falsificar recetas ante Farmacia Robles
Claves privadas TLS 05-02 / incidente 05-03 nginx / balanceador Suplantar portal.medinube.example (MITM)
Credenciales DNS de certbot 05-03 (reto DNS-01) Renovación ACME Emitir certificados válidos para nuestros dominios
Contraseña de la base de datos (implícita desde el módulo 2) La app al conectar a PostgreSQL Acceso directo a los datos cifrados y no cifrados

Fíjate en un detalle: no todos los secretos son iguales. La clave maestra de historiales protege datos sanitarios con obligaciones RGPD de décadas; la clave de un webhook protege notificaciones. El nivel de protección puede (y debe) ser proporcional al impacto.

Jerarquía de claves: envelope encryption con DEK y KEK

En 04-05 construimos el sobre híbrido y anticipamos que el patrón DEK/KEK era "justo lo que hacen los KMS". Ha llegado el momento de saldar esa promesa. La idea del envelope encryption (cifrado de sobre) es organizar las claves en jerarquía:

  • DEK (Data Encryption Key): la clave que cifra los datos. Vive junto a los datos, pero cifrada.
  • KEK (Key Encryption Key): la clave que cifra las DEKs. Vive en el KMS y nunca sale de él.
flowchart TD
    KMS["KMS / Vault<br/>(la KEK nunca sale de aquí)"]
    KEK["KEK<br/>clave maestra raíz"]
    DEK1["DEK historiales<br/>(cifrada con la KEK)"]
    DEK2["DEK backups<br/>(cifrada con la KEK)"]
    DEK3["DEK adjuntos<br/>(cifrada con la KEK)"]
    D1["Historiales AEAD v1"]
    D2["Backups scrypt/sobre"]
    D3["Adjuntos (radiografías...)"]
    KMS --- KEK
    KEK -->|"cifra"| DEK1
    KEK -->|"cifra"| DEK2
    KEK -->|"cifra"| DEK3
    DEK1 -->|"cifra"| D1
    DEK2 -->|"cifra"| D2
    DEK3 -->|"cifra"| D3

El flujo operativo es el punto clave, porque suele malentenderse:

  1. La app quiere descifrar un historial. Junto al dato tiene guardada la DEK cifrada.
  2. Envía la DEK cifrada al KMS: "descíframe esto" (operación decrypt).
  3. El KMS verifica que la app está autorizada, descifra la DEK con la KEK y devuelve la DEK en claro.
  4. La app usa la DEK en memoria para el AES-GCM de medinube.crypto y la descarta al terminar.

Los datos nunca pasan por el KMS. Por el KMS solo viajan claves de 32 bytes, no historiales de megabytes. Esto hace el patrón rápido, barato y auditable: el KMS registra cada operación (quién pidió descifrar qué DEK y cuándo), y rotar la KEK solo obliga a recifrar DEKs (unas pocas), no terabytes de datos.

La escala de madurez de la gestión de secretos

No toda organización necesita un HSM. Lo importante es saber en qué escalón estás y cuál es el siguiente:

Nivel Dónde viven los secretos Cuándo basta Riesgo residual
0. Hardcoded En el código (CLAVES_API) Nunca Todo el que lea el repo tiene los secretos
1. .env fuera del repo Fichero local, en .gitignore Desarrollo local, prototipos Se comparte por canales inseguros; sin rotación ni auditoría
2. Secretos del orquestador/CI Kubernetes Secrets, GitHub Actions secrets, systemd credentials Equipos pequeños con despliegue automatizado Quien administra la plataforma ve todo; auditoría limitada
3. Gestor dedicado Vault, KMS cloud (AWS KMS, GCP KMS, Azure Key Vault), step-ca para certificados (05-03) Producción seria; datos regulados como los de MediNube La app autenticada sigue siendo la frontera
4. HSM Hardware a prueba de manipulación; la clave no puede extraerse ni con root CAs raíz, firma eIDAS cualificada (04-03), requisitos legales Coste y complejidad operativa

MediNube, con historiales médicos y obligaciones RGPD, debe estar en el nivel 3, con la KEK idealmente respaldada por HSM (los KMS cloud ya lo hacen por debajo). El .env del nivel 1 sigue siendo legítimo... para la máquina del desarrollador con datos de prueba.

Variables de entorno: uso correcto y límites

Las variables de entorno son el mecanismo estándar para entregar un secreto a un proceso, y están bien para eso. Pero conviene conocer sus límites:

# medinube/config.py — versión corregida (adiós, diccionario de 01-04)
import os

def clave_api_clinica(clinica_id: str) -> str:
    """Recupera la clave de API de una clínica desde el entorno.

    En producción, el entorno lo puebla el gestor de secretos
    (nivel 3); en desarrollo, un .env local que NUNCA se commitea.
    """
    valor = os.environ.get(f"MEDINUBE_API_KEY_{clinica_id.upper().replace('-', '_')}")
    if valor is None:
        raise RuntimeError(f"Falta la clave de API de {clinica_id}: revisa el gestor de secretos")
    return valor

Explicación: en lugar de tener los valores en el código, el código solo conoce el nombre del secreto. Fallar ruidosamente (RuntimeError) si falta es deliberado: un secreto ausente debe impedir el arranque, no degenerar en un valor por defecto inseguro.

Límites que debes conocer:

  • Visibles en /proc: en Linux, cat /proc/<pid>/environ muestra el entorno de un proceso. Cualquier usuario que pueda leer ese fichero (root, o el mismo usuario) ve los secretos.
  • Se heredan: todo proceso hijo recibe el entorno completo. Ese script de terceros que lanzas también ve MEDINUBE_MASTER_KEY.
  • Acaban en logs y volcados: muchos frameworks imprimen el entorno completo al reportar un error. Configura tu gestor de errores para redactar variables con nombres como *_KEY, *_SECRET, *_PASSWORD.
  • No se rotan solas: cambiar el valor exige reiniciar el proceso.

Ficheros cifrados en el repositorio: sops y age

A veces quieres versionar la configuración con secretos incluidos (por ejemplo, para infraestructura). La respuesta no es "commitear el secreto", es commitearlo cifrado. La pareja sops + age es el estándar ligero:

# Generar un par de claves age (X25519 por debajo — 04-04 en acción)
age-keygen -o ~/.config/sops/age/keys.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

# Cifrar solo los VALORES de un YAML, dejando las claves legibles para el diff
sops --encrypt --age age1ql3z...ac8p secretos.yaml > secretos.enc.yaml
git add secretos.enc.yaml   # esto SÍ puede ir al repo

# Descifrar en despliegue (la clave privada age vive en el servidor, no en el repo)
sops --decrypt secretos.enc.yaml

Lo elegante de sops es que cifra valor a valor: el diff de git sigue mostrando qué clave cambió, sin revelar el contenido. Y puede usar como backend age, un KMS cloud o PGP — criptoagilidad también aquí. El límite: descentraliza la rotación (recifrar y redesplegar) y no da auditoría de accesos. Es un nivel 2.5 en nuestra escala: perfecto para configuración de infraestructura, insuficiente como gestor central de MediNube.

Gestores de secretos: Vault y KMS en la práctica

Un gestor de secretos (HashiCorp Vault, OpenBao, o el KMS/Secrets Manager de tu nube) aporta lo que ningún nivel anterior tiene: autenticación de la aplicación, secretos con caducidad y auditoría central. El flujo conceptual, común a todos los proveedores:

  1. La app se autentica ante el gestor. No con "otro secreto hardcodeado" (¡sería el problema de siempre!), sino con una identidad de plataforma: el service account de Kubernetes, el rol IAM de la instancia cloud, un certificado mTLS de nuestra PKI de 05-01.
  2. El gestor devuelve un token con TTL (tiempo de vida) y, para muchos secretos, un lease: el secreto en sí caduca (piensa en credenciales de BD generadas al vuelo, válidas 1 hora).
  3. La app renueva el lease mientras vive; al morir, el secreto expira solo. Es la misma moraleja de las vidas cortas de los certificados de 05-03 aplicada a todos los secretos.

Con la librería hvac (cliente Python de Vault) el código queda así — trata este ejemplo como flujo, no como atadura a un proveedor:

import hvac

# 1. Autenticación: aquí con AppRole; en Kubernetes sería el JWT del service account
cliente = hvac.Client(url="https://vault.medinube.example:8200")
cliente.auth.approle.login(role_id=ROLE_ID, secret_id=SECRET_ID)

# 2. Leer un secreto estático versionado (motor KV v2)
respuesta = cliente.secrets.kv.v2.read_secret_version(
    path="medinube/produccion/pepper",
)
pepper = respuesta["data"]["data"]["valor"].encode()

# 3. Envelope encryption con el motor "transit": la KEK vive en Vault
#    y NUNCA sale; le mandamos la DEK cifrada y nos la devuelve en claro.
import base64
resultado = cliente.secrets.transit.decrypt_data(
    name="kek-historiales",          # la KEK, identificada por nombre
    ciphertext=dek_cifrada_str,      # "vault:v2:..." — ¡fíjate en el versionado!
)
dek = base64.b64decode(resultado["data"]["plaintext"])

Detalles a saborear: el ciphertext del motor transit empieza por vault:v2: — versionado del formato, exactamente como nuestro byte 0x01 de medinube.crypto. Todo el mundo llega a la misma conclusión: sin versión no hay rotación.

Nota de compliance: para MediNube, el gestor de secretos no es solo buena ingeniería: el RGPD exige "medidas técnicas apropiadas" (art. 32), y ante una auditoría, el registro de accesos del gestor es la evidencia de quién pudo tocar la clave de los historiales. Coordina la elección de nivel con tu responsable de seguridad y tu DPO.

Rotación de la clave maestra: la criptoagilidad se cobra

En 02-03 diseñamos el formato v1 = 0x01 || nonce(12) || cifrado+tag con AAD f"paciente={id};formato=v1" y dijimos que ese byte de versión "algún día nos salvaría". Hoy es ese día: hay que rotar la clave maestra de historiales (buena higiene periódica, y obligatorio si sospechas compromiso). El plan, gracias al versionado:

  1. Se crea la clave nueva en el gestor (o una nueva versión de la KEK, que recifra las DEKs).
  2. Se define v2: idéntico formato, byte 0x02, AAD formato=v2, clave nueva.
  3. Escritura: todo cifrado nuevo sale en v2.
  4. Lectura: descifrar_historial mira el primer byte y elige la clave. v1 sigue siendo legible.
  5. Recifrado progresivo: un job de fondo lee cada historial v1, lo descifra y lo reescribe en v2. Sin parada de servicio.
  6. Cuando no queda ningún v1 en la BD, la clave vieja se destruye (o se archiva según política de retención).
# medinube/crypto.py — descifrado multi-versión
CLAVES = {
    0x01: obtener_del_gestor("kek-historiales", version=1),  # clave vieja: solo lectura
    0x02: obtener_del_gestor("kek-historiales", version=2),  # clave nueva: lectura y escritura
}

def descifrar_historial(blob: bytes, paciente_id: str) -> bytes:
    version = blob[0]
    if version not in CLAVES:
        raise HistorialManipulado(f"Versión de formato desconocida: {version:#04x}")
    aad = f"paciente={paciente_id};formato=v{version}".encode()
    aesgcm = AESGCM(CLAVES[version])
    return aesgcm.decrypt(blob[1:13], blob[13:], aad)

Sin aquel byte inicial, la rotación habría exigido adivinar con qué clave se cifró cada fila (probar y capturar excepciones: lento y feo) o recifrarlo todo de golpe con el servicio parado. La regla de oro 8 —criptoagilidad— acaba de pagarse sola.

El pepper, por fin bien hecho

En 03-03 dejamos el pepper "para cuando tuviéramos un sitio seguro donde guardarlo". Ya lo tenemos. Recordatorio: la sal va junto al hash en la BD (es pública); el pepper es un secreto global que vive fuera de la BD, de modo que un volcado de la base de datos no baste para atacar los hashes offline. La forma correcta no es concatenar, es HMAC (03-02):

# medinube/auth.py — Argon2id + pepper HMAC
import hmac, hashlib
from argon2 import PasswordHasher

ph = PasswordHasher()
PEPPER = obtener_del_gestor("medinube/produccion/pepper")  # 32 bytes del CSPRNG del gestor

def _con_pepper(password: str) -> bytes:
    # HMAC-SHA256(pepper, password): salida uniforme de 32 bytes,
    # sin los problemas de longitud/normalización de concatenar.
    return hmac.new(PEPPER, password.encode(), hashlib.sha256).digest()

def registrar(password: str) -> str:
    return ph.hash(_con_pepper(password))

def verificar(hash_almacenado: str, password: str) -> bool:
    ph.verify(hash_almacenado, _con_pepper(password))  # lanza excepción si falla
    return True

Ahora MalloryClinic necesita dos brechas (la BD y el gestor de secretos) para montar un ataque de diccionario. Ojo: el pepper es difícil de rotar (habría que re-hashear en el siguiente login de cada usuario, como la migración de 03-03), así que protégelo bien desde el principio.

Prevención: que el secreto no llegue al repositorio

El runbook del incidente de 05-03 era reactivo. La versión madura es impedir la filtración antes del commit y detectarla en CI:

# gitleaks: escáner de secretos (detecta claves de API, PEM, tokens...)
# 1) En local, como hook pre-commit (fichero .pre-commit-config.yaml):
#      - repo: https://github.com/gitleaks/gitleaks
#        rev: v8.24.0
#        hooks: [{id: gitleaks}]
pre-commit install

# 2) En CI, escaneando TODO el historial, no solo el último commit:
gitleaks git --redact -v .

# 3) git-secrets (alternativa de AWS) con patrones propios:
git secrets --add 'sk_live_[0-9a-f]{16}'   # el formato de nuestras claves de API

Complementos imprescindibles:

  • .gitignore defensivo: .env, *.pem, *.key, credentials.json deben estar ignorados antes de que existan.
  • Escaneo del historial ya publicado: gitleaks sobre todo el historial descubre secretos filtrados hace meses. Si aparece algo, aplica el runbook de 05-03 generalizado: rotar primero (el secreto ya se considera comprometido), después limpiar el historial, avisar a [email protected] y buscar usos sospechosos en los logs. Limpiar el historial sin rotar es teatro.
  • Redacción en logs: filtros en el logger que enmascaren valores con aspecto de secreto.

Errores Comunes y Consejos

  • Error: borrar el commit y darse por salvado. El secreto sigue en el reflog, en los forks, en las cachés de los escáneres. La única respuesta válida a una filtración es la rotación.
  • Error: autenticar la app ante Vault con un token hardcodeado. Has movido el problema, no lo has resuelto. Usa identidad de plataforma (IAM, service account, mTLS).
  • Error: pasar historiales enteros por el KMS. El KMS cifra claves (DEKs), no datos. Envelope encryption existe justamente para eso.
  • Error: un solo secreto "para todo". Una clave por propósito (regla que ya aplicamos con HKDF en 02-04): así una filtración no arrastra todo el sistema, y cada clave puede rotarse por separado.
  • Consejo: mide tu madurez con la tabla de niveles y sube un escalón cada vez. Pasar de nivel 0 a nivel 1 en una tarde elimina el 80 % del riesgo inmediato.
  • Consejo: trata el inventario de secretos como documentación viva. Cada secreto nuevo entra en la tabla con su dueño, su impacto y su plan de rotación.

Ejercicios

  1. Auditoría exprés: clona (mentalmente o de verdad) un proyecto tuyo y localiza sus secretos. Clasifícalos con la tabla de niveles de madurez: ¿en qué nivel está cada uno? ¿Cuál es el salto de nivel más urgente?
  2. Rotación con versionado: partiendo del descifrar_historial multi-versión de esta lección, escribe cifrar_historial_v2 y el esqueleto del job de recifrado progresivo recifrar_pendientes(lote=100) que busca blobs que empiezan por 0x01, los descifra y los reescribe en v2.
  3. Cazador de secretos: escribe una expresión regular para detectar en pre-commit las claves de API de MediNube (sk_live_ + 16 hex) y los bloques PEM de claves privadas. ¿Por qué conviene añadirla a CI aunque ya esté en pre-commit?

Soluciones

  1. Respuesta abierta, pero el patrón típico: claves de API en nivel 0 o 1, contraseña de BD en nivel 1 o 2, certificados en nivel 2. El salto más urgente casi siempre es sacar del código lo que esté en nivel 0 (una tarde de trabajo) y añadir gitleaks a CI (una hora).

  2. def cifrar_historial_v2(datos: bytes, paciente_id: str) -> bytes:
        nonce = os.urandom(12)
        aad = f"paciente={paciente_id};formato=v2".encode()
        ct = AESGCM(CLAVES[0x02]).encrypt(nonce, datos, aad)
        return bytes([0x02]) + nonce + ct
    
    def recifrar_pendientes(lote: int = 100) -> int:
        filas = bd.query("SELECT id, paciente_id, historial FROM historiales "
                         "WHERE get_byte(historial, 0) = 1 LIMIT %s", [lote])
        for fila in filas:
            datos = descifrar_historial(fila.historial, fila.paciente_id)
            nuevo = cifrar_historial_v2(datos, fila.paciente_id)
            bd.execute("UPDATE historiales SET historial = %s WHERE id = %s",
                       [nuevo, fila.id])
        return len(filas)
    

    Claves del diseño: el job es idempotente (si se interrumpe, la próxima pasada encuentra los que falten), trabaja por lotes para no saturar la BD, y usa el AAD correcto de cada versión (recifrar cambia el AAD de v1 a v2, por eso hay que descifrar y volver a cifrar, no "cambiar el byte").

  3. Patrones: sk_live_[0-9a-f]{16} y -----BEGIN( [A-Z]+)? PRIVATE KEY-----. Hace falta también en CI porque los hooks de pre-commit son opcionales y locales: un compañero sin el hook instalado (o un git commit --no-verify) se los salta; CI es la red de seguridad que nadie puede eludir.

Conclusión

Hemos cerrado la herida por la que sangraba todo el curso: los secretos de MediNube ya no viven en diccionarios del código ni en .env compartidos, sino en un gestor con autenticación, TTLs y auditoría; la clave maestra de historiales rota gracias al versionado v1/v2 que sembramos en el módulo 2; el pepper por fin existe de verdad; y gitleaks vigila que la historia del privkey.pem no se repita. El envelope encryption DEK/KEK ha pasado de promesa (04-05) a arquitectura.

Pero tener las claves bien guardadas es solo la mitad del mapa: ahora hay que decidir qué se cifra, dónde y contra qué amenaza en cada rincón de la arquitectura de MediNube — del navegador de Ana Pérez a la fila de PostgreSQL, pasando por los backups. Ese mapa completo del cifrado en reposo y en tránsito es la próxima lección. Nos vemos allí.

© Copyright 2026. Todos los derechos reservados