Ya tenemos todas las piezas: AEAD versionado, KDFs, firmas, TLS, mTLS y, desde la lección anterior, un gestor de secretos con envelope encryption. Esta lección cambia de altitud: dejamos de mirar primitivas y adoptamos la visión de arquitecto. La pregunta ya no es "¿cómo funciona AES-GCM?" sino "¿qué datos de MediNube están cifrados, con qué capa, y contra qué atacante protege cada capa?". Vas a dibujar el mapa completo del dato — desde el navegador de Ana Pérez hasta la fila de PostgreSQL y la cinta de backup — y a descubrir que "está cifrado" es una frase que no significa nada hasta que respondes: ¿cifrado dónde y contra quién?
Contenido
- El mapa de datos de MediNube: estados y fronteras
- En tránsito: TLS externo e interno (la BD también habla TLS)
- En reposo: las cuatro capas y qué amenaza cubre cada una
- Búsqueda sobre datos cifrados: el dilema y el índice ciego
- Hashear, cifrar o dejar en claro: la tabla de decisión
- El volcado robado: ejercicio mental guiado
- Cifrado de extremo a extremo: el maximalismo y por qué MediNube no lo es
El mapa de datos de MediNube: estados y fronteras
Todo dato está en uno de dos estados: en tránsito (viajando por una red) o en reposo (escrito en un disco). Cada estado tiene sus amenazas y sus herramientas. Este es el mapa de MediNube con lo que ya hemos construido:
flowchart LR
subgraph internet["Internet"]
NAV["Navegador de Ana Pérez"]
CLI["Sistemas de Clínica Sol<br/>(mTLS, 05-02)"]
end
subgraph dmz["Perímetro MediNube"]
NGINX["nginx<br/>TLS 1.3 (05-02)<br/>/etc/medinube/tls/"]
end
subgraph interna["Red interna"]
APP["App MediNube<br/>cifrado de aplicación (02-03)<br/>DEKs vía KMS (06-01)"]
PG[("PostgreSQL<br/>TLS interno + disco cifrado")]
BK[("Backups<br/>scrypt (02-04) + sobre (04-05)")]
S3[("Almacén de objetos<br/>adjuntos cifrados")]
end
NAV -->|"TLS 1.3"| NGINX
CLI -->|"mTLS"| NGINX
NGINX -->|"TLS interno"| APP
APP -->|"TLS: sslmode=verify-full"| PG
APP -->|"TLS"| S3
PG -->|"pg_dump cifrado"| BK
Lección de arquitecto número uno: las flechas son tránsito (dominio de TLS, módulo 5) y los cilindros son reposo (dominio de los módulos 2 y 4). Lección número dos: el interior de la red "de confianza" también lleva candados — la época de "TLS solo en el perímetro" terminó cuando los atacantes aprendieron a moverse lateralmente por redes internas.
En tránsito: TLS externo e interno
El tramo externo ya lo resolvimos en el módulo 5 y aquí solo lo reutilizamos: TLS 1.3 en portal.medinube.example y api.medinube.example, mTLS para las clínicas con su SAN clinica-sol.clientes.medinube.example, HSTS, certificados de vida corta con ACME. Nada nuevo.
Lo que sí es nuevo es mirar hacia dentro: la conexión app ↔ PostgreSQL también cruza una red, y en esa red puede haber un contenedor comprometido escuchando. PostgreSQL habla TLS de forma nativa, y el cliente decide cuánto exige con sslmode:
sslmode |
¿Cifra? | ¿Verifica el certificado? | ¿Verifica el nombre del servidor? |
|---|---|---|---|
disable |
No | — | — |
require |
Sí | No | No |
verify-ca |
Sí | Sí (contra una CA) | No |
verify-full |
Sí | Sí | Sí |
Aquí está la trampa que sorprende a casi todo el mundo: require cifra pero no verifica. Acepta cualquier certificado, incluido el autofirmado que MalloryClinic presente en un MITM dentro de la red. Es el verify=False de 05-02 disfrazado de opción razonable: canal cifrado con un desconocido. La configuración correcta usa la CA interna que creamos en 05-01:
# medinube/db.py — conexión a PostgreSQL con TLS verificado de verdad
import psycopg
conexion = psycopg.connect(
host="pg.interna.medinube.example",
dbname="medinube",
user="app_medinube",
password=obtener_del_gestor("medinube/produccion/db-password"), # 06-01
sslmode="verify-full", # cifrado + cadena + nombre del host
sslrootcert="/etc/medinube/pki/ca.crt", # nuestra CA interna de 05-01
)Explicación: sslrootcert apunta a la raíz MediNube Lab Root CA desplegada en 05-01; verify-full obliga a que el certificado del servidor esté firmado por esa CA y que su SAN coincida con pg.interna.medinube.example. Es exactamente la validación de cadena+nombre del módulo 5, aplicada a la base de datos. La contraseña, por supuesto, sale del gestor de la lección anterior.
En reposo: las cuatro capas y qué amenaza cubre cada una
"La base de datos está cifrada" puede significar cuatro cosas muy distintas. La tabla que sigue es probablemente la más importante de la lección — cada capa protege contra un atacante concreto y es transparente para todos los demás:
| Capa | Ejemplo | Protege contra | NO protege contra |
|---|---|---|---|
| Disco / volumen | LUKS, cifrado por defecto del cloud | Robo físico del disco, RMA/desecho de hardware | Cualquier atacante con el sistema operativo arrancado (el SO ve el disco descifrado) |
| Base de datos (TDE) | TDE de motores comerciales; pgcrypto por columnas |
Robo de los ficheros de datos y de algunos backups en frío | Atacante con acceso SQL, inyección SQL, DBA malicioso (según variante) |
| Aplicación | medinube.crypto (02-03): AEAD antes del INSERT |
BD comprometida por completo: volcado, inyección, DBA curioso | Compromiso de la propia app o del KMS; no permite consultar el campo |
| Backups | scrypt (02-04) + sobre híbrido (04-05) | Pérdida/robo del soporte de backup, repositorios de backup ajenos | Nada más: es cifrado de aplicación aplicado al fichero de backup |
Tres comentarios de profundidad:
-
El cifrado de disco es necesario y casi nunca suficiente. Cuando el servidor está encendido (siempre, en un SaaS), LUKS es transparente: cualquier proceso con permisos lee los datos en claro. Su escenario real es el disco que sale del centro de datos. Actívalo siempre (en cloud suele venir de serie), pero no lo pongas en el informe de seguridad como "los historiales están cifrados".
-
TDE y
pgcryptoviven dentro de la BD. TDE (Transparent Data Encryption) cifra los ficheros del motor: mismo alcance que el disco, más granular.pgcryptopermite cifrar columnas desde SQL:-- pgcrypto: cifrado simétrico por columna, desde el propio SQL UPDATE pacientes SET telefono = pgp_sym_encrypt('612345678', 'clave-secreta') WHERE id = 4471; SELECT pgp_sym_decrypt(telefono::bytea, 'clave-secreta') FROM pacientes;Su límite salta a la vista: la clave viaja en la consulta SQL, con lo que acaba en los logs de la BD, en
pg_stat_activityy en manos de cualquier atacante que ya esté dentro del motor... que es justo el atacante del que queríamos protegernos. Útil en escenarios acotados; no para los historiales de MediNube. -
El cifrado a nivel de aplicación es el más granular y el más fuerte. Es el que construimos en 02-03: el historial de Ana Pérez se cifra con AES-256-GCM (formato
v1, AADpaciente=...;formato=v1) antes de salir de la app, y viaja ya cifrado hasta la fila de PostgreSQL. La BD solo ve bytes opacos: un volcado completo, una inyección SQL o un administrador curioso obtienen exactamente nada. La DEK llega por el envelope encryption de 06-01. El precio también es real, y le dedicamos la siguiente sección: la BD ya no puede indexar ni buscar sobre ese campo.
Las capas no compiten: se apilan. MediNube lleva disco cifrado (contra el robo físico), TLS interno (contra la red), cifrado de aplicación en los campos sensibles (contra la BD comprometida) y backups cifrados con el sobre híbrido de 04-05 cuando el destinatario es externo (la clínica que pide su copia: se cifra la DEK del backup con la pública de la clínica).
Búsqueda sobre datos cifrados: el dilema y el índice ciego
Aquí aparece el conflicto central del cifrado de aplicación. Un campo AEAD bien cifrado es indistinguible de bytes aleatorios y, además, cifrar dos veces el mismo valor produce blobs distintos (nonce aleatorio). Consecuencia directa:
-- Esto NO PUEDE funcionar: cada cifrado del mismo DNI es distinto SELECT * FROM pacientes WHERE dni_cifrado = '???';
No es un defecto: es la semántica de seguridad que queríamos. Pero recepción necesita buscar a Ana Pérez por DNI. El patrón práctico es el índice ciego (blind index): junto al campo cifrado se guarda un HMAC del valor normalizado, con una clave dedicada del gestor:
# medinube/indices.py — índice ciego para búsqueda por igualdad exacta
import hmac, hashlib
CLAVE_INDICE_DNI = obtener_del_gestor("medinube/produccion/indice-dni") # SOLO para esto
def indice_ciego_dni(dni: str) -> bytes:
normalizado = dni.strip().upper().replace("-", "").replace(" ", "")
return hmac.new(CLAVE_INDICE_DNI, normalizado.encode(), hashlib.sha256).digest()
# Al guardar: INSERT ... (dni_cifrado, dni_idx) VALUES (aead(dni), indice_ciego_dni(dni))
# Al buscar: SELECT ... WHERE dni_idx = %s ← determinista: mismo DNI, mismo HMACPor qué cada pieza es como es: la normalización garantiza que "12345678-Z" y "12345678z" produzcan el mismo índice (si no, la búsqueda falla por formato); el HMAC con clave (y no un simple sha256) impide que quien robe la tabla calcule el índice de todos los DNIs posibles por fuerza bruta —los DNIs tienen poquísima entropía, regla de oro 5—; y la clave dedicada limita el daño si se filtra.
Honestidad obligada sobre lo que el índice ciego filtra: es determinista, así que revela qué filas comparten valor (igualdad) y permite confirmar un DNI concreto a quien tenga la clave del índice. Solo sirve para igualdad exacta: nada de LIKE 'PER%', rangos ni ordenación. Existen esquemas más ambiciosos (cifrado que preserva orden, cifrado buscable), pero sus fugas son mucho peores de lo que parecen y su historia académica está llena de roturas: para MediNube, igualdad exacta con índice ciego y cualquier búsqueda más rica sobre campos no sensibles.
Hashear, cifrar o dejar en claro: la tabla de decisión
Con las capas y el índice ciego sobre la mesa, ya podemos decidir campo a campo. Esta es la tabla real de la tabla pacientes y compañía en MediNube — el criterio: ¿necesito recuperar el valor original? ¿necesito buscar por él? ¿qué pasa si se filtra?
| Columna | Tratamiento | Por qué |
|---|---|---|
id (interno) |
Claro | Identificador opaco sin significado externo; las FKs y JOINs lo necesitan |
dni |
AEAD + índice ciego | Hay que mostrarlo (recuperable) y buscar por él (índice); dato identificativo fuerte |
nombre |
AEAD (cifrado) | Recuperable para mostrarlo; no se busca por igualdad exacta (la búsqueda de pacientes usa otros caminos) |
contraseña |
Argon2id + pepper (03-03/06-01) | Nunca necesitamos el original: verificar ≠ recuperar. Hashear, jamás cifrar |
historial |
AEAD v1/v2 con AAD |
El dato más sensible; nunca se consulta por contenido desde SQL |
email |
AEAD + índice ciego | Recuperable (hay que enviarle correos) y buscable (login) |
token_recuperacion |
sha256(token) (03-03) |
Solo se compara, nunca se muestra; el token real solo lo tiene el usuario |
fecha_alta, estado |
Claro | Necesarios para operar, ordenar y depurar; riesgo bajo aislados |
La fila de la contraseña merece subrayado: cifrar una contraseña (recuperable) en lugar de hashearla es un error clásico de auditoría. Si el sistema puede recuperar tu contraseña, un atacante también.
El volcado robado: ejercicio mental guiado
Pongamos a prueba el diseño. MalloryClinic consigue un pg_dump completo de la BD de MediNube (una inyección SQL, un backup mal protegido, da igual el cómo). Recorramos qué obtiene según las capas presentes:
- Sin ninguna capa (la MediNube del módulo 1): todo en claro. Historiales, DNIs, contraseñas. Brecha catastrófica, notificación a la AEPD, portada de prensa.
- Solo disco cifrado: exactamente lo mismo que el punto 1. El volcado se hizo con el sistema arrancado; LUKS ni se enteró. Primera lección grabada a fuego: el cifrado de disco no protege contra volcados lógicos.
- + TDE / cifrado de la BD: también casi lo mismo: el volcado lógico sale del motor, que descifra de forma transparente. TDE protegería el robo de los ficheros del motor, no del dump.
- + cifrado de aplicación (la MediNube actual): los historiales y DNIs son blobs AEAD sin las DEKs (que están en el KMS, no en la BD); las contraseñas son Argon2id y además falta el pepper (que vive en el gestor); los índices ciegos no se invierten sin su clave. Mallory tiene ids, fechas y metadatos. Sigue siendo un incidente (los metadatos también cuentan para el RGPD y hay que notificarlo), pero el contenido clínico de Ana Pérez está a salvo.
Este ejercicio —"¿qué ve el atacante con este volcado?"— es la herramienta de diseño más barata que existe. Hazlo con cada tabla nueva antes de escribir la migración.
Cifrado de extremo a extremo: el maximalismo y por qué MediNube no lo es
Queda una capa por encima de todas: el cifrado de extremo a extremo (E2E), donde ni siquiera el servidor puede leer los datos — solo los extremos tienen las claves. Signal, que ya mencionamos en el módulo 4, es el ejemplo canónico: el servidor de Signal transporta blobs que no puede abrir.
¿Podría MediNube ser E2E, con los historiales cifrados con claves que solo tienen las clínicas y los pacientes? Técnicamente sí (las piezas son las del módulo 4: X25519, sobres, firmas). Pero implicaría renunciar a funciones que hoy son el producto: el servidor no podría generar informes agregados, ni indexar para búsqueda, ni permitir que un médico de urgencias acceda a un historial si el paciente no está para "autorizar" criptográficamente; y la pérdida de la clave del paciente significaría la pérdida irrecuperable de su historial (o un sistema de recuperación... que reintroduce la confianza en el servidor). E2E es la elección correcta cuando el servidor es solo un transporte (mensajería); MediNube es un procesador de datos, y su compromiso honesto es otro: cifrado de aplicación con claves en KMS auditado, de modo que leer datos exija comprometer dos sistemas y deje rastro. Decir "somos E2E" sin serlo sería marketing, no criptografía — y las auditorías RGPD distinguen perfectamente ambas cosas.
Errores Comunes y Consejos
- Error:
sslmode=requirey sensación de seguridad. Cifra, pero acepta cualquier certificado. Contra un MITM interno es inútil:verify-full+ tu CA interna, siempre. - Error: contar el cifrado de disco como cifrado de datos. Protege discos apagados. En el informe de seguridad, especifica capa por capa qué hay.
- Error: pasar la clave de
pgcryptoen cada consulta y encontrársela después en los logs de PostgreSQL. Si necesitas protección contra la propia BD, cifra en la aplicación. - Error: índice ciego con hash sin clave (
sha256(dni)): con ~10⁸ DNIs posibles, se invierte por fuerza bruta en minutos. HMAC con clave del gestor, y normaliza antes. - Consejo: apila capas según el atacante, no según la moda. La pregunta de diseño siempre es "¿contra quién?": robo físico → disco; red interna → TLS con
verify-full; BD comprometida → aplicación; soporte externo → backups con sobre. - Consejo: documenta la tabla de decisión de campos (claro/hash/cifrado/índice) en el repositorio. Es oro para el siguiente desarrollador y para el auditor.
Ejercicios
- Clasifica la tabla nueva: MediNube añade la tabla
citascon columnasid,paciente_id,medico_id,fecha_hora,motivo_consulta(texto libre: "dolor torácico..."),telefono_contacto. Asigna tratamiento a cada columna (claro / AEAD / AEAD+índice ciego / hash) y justifica. Pista: recepción necesita buscar citas por teléfono de contacto. - El volcado, otra vez: con tu diseño del ejercicio 1, escribe qué obtiene exactamente MalloryClinic de un
pg_dumpdecitas, y qué metadatos siguen filtrándose aunque todo lo sensible esté cifrado. ¿Es "cita del paciente X con el médico Y a las 9:00" un dato inocuo? - Cazando el
require: escribe la cadena de conexión (o el bloquepsycopg.connect) incorrecta consslmode=requirey su corrección converify-full, y explica en dos frases el ataque concreto que la versión incorrecta permite dentro de la red de MediNube.
Soluciones
id,paciente_id,medico_id: claro (claves de JOIN; son referencias opacas).fecha_hora: claro (hay que consultar agendas por rango:WHERE fecha_hora BETWEEN..., imposible sobre AEAD y el índice ciego no cubre rangos).motivo_consulta: AEAD con AAD (p. ej.f"cita={id};formato=v2") — es contenido clínico, tan sensible como el historial.telefono_contacto: AEAD + índice ciego con normalización (quitar espacios, prefijo +34) porque hay que recuperarlo (llamar al paciente) y buscar por él.- Mallory ve el grafo de citas: qué
paciente_idvisita a quémedico_idy cuándo, sin motivos ni teléfonos. No es inocuo: si el médico Y es identificable como oncólogo, la mera existencia de citas frecuentes revela información de salud — por eso los metadatos también son datos personales bajo RGPD y el incidente igualmente se notifica. Mitigaciones posibles: pseudonimizarmedico_ida nivel de aplicación o cifrar también esa relación aceptando el coste en consultas. - Incorrecta:
psycopg.connect(..., sslmode="require"). Correcta:sslmode="verify-full", sslrootcert="/etc/medinube/pki/ca.crt". Ataque: un pod comprometido en la red interna hace ARP/DNS spoofing haciapg.interna.medinube.example, presenta un certificado autofirmado y el cliente conrequirelo acepta sin rechistar: MalloryClinic termina el TLS, lee credenciales y consultas, y reenvía el tráfico a la BD real (MITM completo e invisible).
Conclusión
Ya no piensas en primitivas: piensas en capas. Sabes que "cifrado" sin apellidos no significa nada, que cada capa (disco, BD, aplicación, backup) tiene su atacante, que la app habla con PostgreSQL con verify-full contra la CA interna de 05-01, que buscar sobre datos cifrados se paga con índices ciegos y honestidad sobre sus fugas, y que el historial de Ana Pérez llega cifrado hasta la fila de la base de datos con DEKs que viven en el KMS de 06-01. El mapa de datos de MediNube está completo.
Queda un dato viajero que aún arrastra una vergüenza del módulo 1: el token de sesión del portal, aquel eyJ... que en 01-02 resultó ser Base64 sin firma — codificar no es cifrar... y tampoco es autenticar. En la próxima lección lo convertimos en lo que siempre debió ser: un token firmado. JWT, sus ataques clásicos y cómo usarlo bien. Nos vemos allí.
Curso de Criptografía Aplicada
Módulo 1: Fundamentos de la Criptografía
- Qué es la criptografía y para qué sirve
- Codificación, ofuscación y cifrado
- Aleatoriedad y entropía
- El principio de Kerckhoffs y las reglas de oro
Módulo 2: Criptografía Simétrica
- Cifrado simétrico: AES y ChaCha20
- Modos de operación
- Cifrado autenticado (AEAD)
- Derivación de claves (KDF)
Módulo 3: Hashes, MAC y Contraseñas
- Funciones hash criptográficas
- Autenticación de mensajes con HMAC
- Almacenamiento seguro de contraseñas
Módulo 4: Criptografía Asimétrica
- Fundamentos de clave pública y RSA
- Criptografía de curva elíptica
- Firmas digitales
- Intercambio de claves: Diffie-Hellman
- Cifrado híbrido
Módulo 5: PKI, Certificados y TLS
- Certificados X.509 y autoridades de certificación
- TLS en la práctica
- Gestión del ciclo de vida de certificados
