Llegamos a la lección que salda la deuda del curso. En 02-02 dejamos un agujero incómodo: CBC y CTR ciframos, sí, pero un atacante puede modificar el cifrado sin la clave y sin que nadie lo note — el bit-flipping del último ejercicio convirtió un saldo de 10 en 90 con precisión quirúrgica. Cifrar da confidencialidad, no integridad. La respuesta profesional a este problema tiene nombre: AEAD, Authenticated Encryption with Associated Data, cifrado autenticado con datos asociados. Combina confidencialidad, integridad y autenticidad en una sola operación imposible de usar a medias. En esta lección conocerás AES-GCM y ChaCha20-Poly1305, entenderás qué son el tag y los datos asociados (AAD), verás Fernet como receta de alto nivel y — el momento que llevamos esperando desde el módulo 1 — construirás el módulo medinube.crypto que cifra por fin los historiales de MediNube en disco, con el formato versionado v1 prometido en 01-04.
Contenido
- El problema: cifrar no impide manipular
- Qué añade AEAD y cómo lo hace (el tag)
- AES-GCM con
AESGCM - ChaCha20-Poly1305 con
ChaCha20Poly1305 - Datos asociados (AAD): autenticar sin cifrar
- Fernet: la receta de alto nivel
- Caso central: cifrar los historiales de MediNube (
medinube.crypto, formatov1)
El problema: cifrar no impide manipular
Un cifrado se llama maleable cuando un atacante puede transformar el texto cifrado para producir un cambio predecible en el texto claro, sin conocer la clave. En 02-02 lo vimos en CTR: como cifrado = mensaje XOR keystream, voltear un bit del cifrado voltea exactamente ese bit del mensaje. CBC es maleable de otra forma (manipular un bloque cifrado corrompe ese bloque pero altera de forma controlada el siguiente). El patrón conceptual del ataque, sobre un campo de un historial de MediNube:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clave, nonce = os.urandom(32), os.urandom(16)
historial = b"ALERGIA: penicilina TRATAMIENTO: activo"
c = Cipher(algorithms.AES(clave), modes.CTR(nonce)).encryptor()
cifrado = bytearray(c.update(historial) + c.finalize())
# El atacante NO conoce la clave, pero sí la plantilla (Kerckhoffs).
# Sabe que en el índice 40-6=... el campo TRATAMIENTO acaba en "activo".
# Cambia "activo" por "cesado" volteando los bytes exactos:
pos = historial.index(b"activo")
for i, (a, b) in enumerate(zip(b"activo", b"cesado")):
cifrado[pos + i] ^= a ^ b
d = Cipher(algorithms.AES(clave), modes.CTR(nonce)).decryptor()
print(d.update(bytes(cifrado)) + d.finalize())
# b'ALERGIA: penicilina TRATAMIENTO: cesado'El atacante ha alterado un historial clínico sin descifrarlo. En un sistema médico, poder cambiar "TRATAMIENTO: activo" por "cesado" — o peor, borrar una alergia — es tan grave como poder leerlo. Cifrar sin autenticar es media protección, y la mitad que falta puede costar vidas. Necesitamos que descifrar falle ruidosamente si un solo bit ha cambiado.
Qué añade AEAD y cómo lo hace (el tag)
Un esquema AEAD garantiza tres cosas de una vez:
- Confidencialidad: nadie sin la clave lee el mensaje (como CTR/CBC).
- Integridad: cualquier modificación del cifrado se detecta.
- Autenticidad: solo quien tiene la clave pudo haber producido ese cifrado.
¿Cómo? Al cifrar, el algoritmo calcula además un tag de autenticación (authentication tag): un valor corto — típicamente 16 bytes — que es una especie de "sello" criptográfico sobre el cifrado y la clave. Al descifrar, el algoritmo recalcula el tag y lo compara: si no coincide (porque alguien tocó un bit), la operación lanza una excepción y no devuelve nada. No hay "descifrar a medias": o los datos están intactos y auténticos, o error.
flowchart LR
subgraph Cifrar
M[mensaje] --> ENC[AEAD encrypt]
K1[clave] --> ENC
N1[nonce] --> ENC
ENC --> CT[cifrado + tag]
end
subgraph Descifrar
CT --> DEC[AEAD decrypt]
K2[clave] --> DEC
N2[nonce] --> DEC
DEC -->|tag OK| OUT[mensaje]
DEC -->|tag mal| ERR[⚠ excepción: datos manipulados]
end
La comparación del tag se hace, por supuesto, en tiempo constante (recuerda compare_digest, 01-04): la librería lo gestiona por ti. Este es el gran valor de AEAD y la razón por la que hoy es el estándar de facto: es muy difícil de usar mal. No hay que acordarse de añadir integridad aparte ni de validar padding con cuidado — viene todo integrado. Los dos AEAD que usarás son las versiones "con superpoderes" de los algoritmos del módulo:
- AES-GCM = AES en modo CTR (confidencialidad) + un autenticador llamado GHASH (integridad).
- ChaCha20-Poly1305 = ChaCha20 (confidencialidad) + el autenticador Poly1305 (integridad).
AES-GCM con AESGCM
GCM (Galois/Counter Mode) es el modo AEAD más usado del mundo: es el que domina en TLS. En pyca/cryptography vive en la capa hazmat pero con una API de alto nivel muy cómoda, la clase AESGCM.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
clave = AESGCM.generate_key(bit_length=256) # 32 bytes del CSPRNG; nuestro estándar
aesgcm = AESGCM(clave)
nonce = os.urandom(12) # GCM usa nonce de 12 bytes (96 bits)
mensaje = "ALERGIA: penicilina".encode("utf-8")
# cifrar(nonce, datos, aad). El tag va INCLUIDO al final del cifrado devuelto.
cifrado = aesgcm.encrypt(nonce, mensaje, None) # None = sin datos asociados (de momento)
# descifrar valida el tag; si algo cambió, lanza InvalidTag.
recuperado = aesgcm.decrypt(nonce, cifrado, None)
print(recuperado.decode("utf-8")) # ALERGIA: penicilina
# Prueba de integridad: manipulamos un byte del cifrado.
manipulado = bytearray(cifrado)
manipulado[0] ^= 1
try:
aesgcm.decrypt(nonce, bytes(manipulado), None)
except Exception as e:
print(type(e).__name__) # InvalidTag ✅ detectadoDetalles cruciales:
- Nonce de 12 bytes. Es el tamaño canónico y recomendado para GCM (no los 16 de CTR/CBC). Y sigue vigente la regla de oro de 02-02: nunca repitas la pareja clave+nonce. En GCM la reutilización es incluso más grave que en CTR, porque además de filtrar los mensajes, permite recuperar la clave interna de autenticación y falsificar tags. Genera el nonce con
os.urandom(12)en cada cifrado. - El tag va incluido.
encryptdevuelvecifrado || tagen un solobytes;decryptespera ese mismo formato. No tienes que gestionar el tag por separado. - Límite de uso del mismo nonce/clave. Con nonces aleatorios de 12 bytes, GCM tiene un límite práctico de mensajes por clave (del orden de 2^32 antes de que la probabilidad de colisión de nonce deje de ser despreciable). Muy holgado para MediNube, pero por eso el módulo 6 hablará de rotación de claves. Retenlo como límite existente, no infinito.
AESGCM.generate_key(bit_length=256)es un atajo cómodo equivalente aos.urandom(32).
ChaCha20-Poly1305 con ChaCha20Poly1305
La alternativa de flujo tiene una API idéntica, lo que hace trivial cambiar de una a otra (criptoagilidad, 01-04):
import os
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
clave = ChaCha20Poly1305.generate_key() # 32 bytes (ChaCha20 es siempre 256 bits)
chacha = ChaCha20Poly1305(clave)
nonce = os.urandom(12) # también 12 bytes
mensaje = "ALERGIA: penicilina".encode("utf-8")
cifrado = chacha.encrypt(nonce, mensaje, None)
recuperado = chacha.decrypt(nonce, cifrado, None)
print(recuperado.decode("utf-8")) # ALERGIA: penicilinaMisma firma (nonce, datos, aad), mismo nonce de 12 bytes, mismo tag incluido, misma regla de no reutilizar nonce. La elección entre AESGCM y ChaCha20Poly1305 es la de 02-01: AES-GCM en servidores con AES-NI (el caso de MediNube), ChaCha20-Poly1305 en móvil/embebido. Por eso medinube.crypto centralizará la elección: cambiar de uno a otro será tocar un solo sitio.
Datos asociados (AAD): autenticar sin cifrar
La "AD" de AEAD son los Associated Data (datos asociados): información que quieres autenticar pero NO cifrar. Van en el tercer parámetro de encrypt/decrypt. El tag se calcula también sobre ellos, así que si cambian, decrypt falla — pero viajan en claro.
¿Cuándo sirve esto? Cuando un dato debe ir visible (porque el sistema lo necesita para enrutar, versionar o indexar antes de descifrar) pero no debe poder falsificarse. Ejemplos típicos en MediNube:
- El identificador de paciente y la versión del formato: deben leerse sin descifrar (para saber a quién pertenece el registro y con qué esquema descifrarlo), pero no deben poder alterarse. Si un atacante intenta "reetiquetar" el historial cifrado de Ana Pérez como si fuera de otro paciente, cambiar el AAD hará que
decryptfalle.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(AESGCM.generate_key(bit_length=256))
nonce = os.urandom(12)
historial = b"ALERGIA: penicilina"
aad = b"paciente=ana.perez;formato=v1" # autenticado, viaja EN CLARO
cifrado = aesgcm.encrypt(nonce, historial, aad)
# Descifrar con el MISMO aad funciona:
print(aesgcm.decrypt(nonce, cifrado, aad)) # b'ALERGIA: penicilina'
# Descifrar con un aad distinto (atacante reetiqueta el paciente) FALLA:
try:
aesgcm.decrypt(nonce, cifrado, b"paciente=otro;formato=v1")
except Exception as e:
print(type(e).__name__) # InvalidTag ✅El AAD ata el cifrado a su contexto: el registro solo es válido para ese paciente y esa versión. Es una herramienta potentísima y la usaremos en el formato v1.
Fernet: la receta de alto nivel
Todo lo anterior es la capa hazmat. Para muchos casos no necesitas bajar tanto: pyca/cryptography ofrece Fernet, una receta de alto nivel que toma todas las decisiones por ti.
from cryptography.fernet import Fernet clave = Fernet.generate_key() # clave lista para usar (formato Base64 urlsafe) f = Fernet(clave) token = f.encrypt(b"ALERGIA: penicilina") # devuelve un token Base64 autoconentido print(f.decrypt(token)) # b'ALERGIA: penicilina'
Qué hace Fernet por dentro, para que sepas qué estás aceptando:
- Cifra con AES-128 en modo CBC y autentica con HMAC-SHA256 (el HMAC lo veremos en el módulo 3): es AEAD "compuesto a mano por expertos".
- Genera el IV automáticamente, incluye un timestamp (permite caducar tokens con
decrypt(token, ttl=...)), y empaqueta todo — versión, timestamp, IV, cifrado, tag — en un único token Base64 que puedes guardar como texto. - Gestiona el formato y la rotación de claves (
MultiFernet).
AESGCM / ChaCha20Poly1305 (hazmat) |
Fernet (alto nivel) | |
|---|---|---|
| Nivel | Primitiva AEAD | Receta completa |
| Control | Total (nonce, AAD, algoritmo, formato) | Ninguno: decide la librería |
| Algoritmo | AES-256-GCM / ChaCha20-Poly1305 | AES-128-CBC + HMAC-SHA256 |
| Formato de salida | bytes crudos (tú diseñas el envoltorio) |
Token Base64 autoconentido |
| AAD | Sí | No |
| Cuándo usarlo | Necesitas AAD, formato propio, AES-256, o rendimiento | Caso general "cifra esto y ya", tokens |
Cuándo basta Fernet: cuando quieres "cifrar esto de forma segura" sin diseñar un formato, no necesitas AAD y AES-128 te vale. Para MediNube, sin embargo, queremos AES-256 (nuestro estándar), AAD (atar el historial a su paciente) y un formato versionado propio (v1, criptoagilidad): por eso construiremos sobre AESGCM. Regla profesional: usa Fernet salvo que tengas una razón concreta para bajar a hazmat — y aquí la tenemos.
Caso central: cifrar los historiales de MediNube (medinube.crypto, formato v1)
Ha llegado el momento. Desde el módulo 1 arrastramos la deuda de que los historiales de pacientes como Ana Pérez están sin cifrar en disco. Vamos a saldarla en el módulo centralizado medinube.crypto que prometimos en 01-04, con el formato v1 = AES-256-GCM.
Diseño del formato del registro cifrado (lo que se guarda en disco), un bytes autoconentido:
┌─────────┬───────────────┬──────────────────────────┐ │ versión │ nonce │ cifrado + tag │ │ 1 byte │ 12 bytes │ (resto) │ └─────────┴───────────────┴──────────────────────────┘ 0x01 os.urandom(12) AESGCM.encrypt(...)
- El byte de versión al principio hace el formato criptoágil: mañana
v2podrá ser ChaCha20-Poly1305 o AES con otra gestión de clave, y el código sabrá por el primer byte cómo descifrar cada registro heredado (01-04). - El nonce viaja junto al cifrado (es público; su única obligación es no repetirse) — así resolvemos el "hay que guardar el IV/nonce" de 02-02.
- Como AAD metemos el id de paciente y la versión: atan el registro a su dueño y a su esquema, autenticados aunque no cifrados.
# medinube/crypto.py — módulo criptográfico centralizado de MediNube.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
VERSION_V1 = 0x01 # 1 byte: identifica el formato AES-256-GCM
class HistorialManipulado(Exception):
"""El registro fue alterado, corrompido o descifrado con la clave/paciente equivocados."""
def cifrar_historial_v1(clave: bytes, id_paciente: str, texto_claro: bytes) -> bytes:
"""Cifra un historial con AES-256-GCM. Devuelve: version || nonce || (cifrado+tag)."""
aesgcm = AESGCM(clave) # clave = 32 bytes (de dónde sale: lección 02-04)
nonce = os.urandom(12) # nonce ÚNICO por registro (regla de oro 02-02)
aad = f"paciente={id_paciente};formato=v1".encode("utf-8")
cifrado = aesgcm.encrypt(nonce, texto_claro, aad)
return bytes([VERSION_V1]) + nonce + cifrado
def descifrar_historial(clave: bytes, id_paciente: str, registro: bytes) -> bytes:
"""Descifra un registro. Lanza HistorialManipulado si el tag no valida."""
version = registro[0]
if version != VERSION_V1:
raise ValueError(f"Versión de formato desconocida: {version}")
nonce = registro[1:13] # 12 bytes tras el byte de versión
cifrado = registro[13:] # cifrado + tag
aad = f"paciente={id_paciente};formato=v1".encode("utf-8")
aesgcm = AESGCM(clave)
try:
return aesgcm.decrypt(nonce, cifrado, aad)
except Exception: # cryptography lanza InvalidTag
raise HistorialManipulado(
"El historial fue manipulado o la clave/paciente no coinciden."
)Uso completo, cerrando el círculo con Ana Pérez:
import os
from medinube.crypto import cifrar_historial_v1, descifrar_historial, HistorialManipulado
clave = os.urandom(32) # clave maestra provisional; su origen real es la lección 02-04
historial = "Paciente Ana Perez. ALERGIA: penicilina. Grupo 0+.".encode("utf-8")
registro = cifrar_historial_v1(clave, "ana.perez", historial)
# 'registro' es lo que se guarda en disco: ilegible y a prueba de manipulaciones.
print(registro[:1].hex(), "->", "versión") # 01 -> versión
# Lectura legítima:
print(descifrar_historial(clave, "ana.perez", registro).decode("utf-8"))
# Paciente Ana Perez. ALERGIA: penicilina. Grupo 0+.
# Ataque 1: manipular el cifrado (el bit-flipping de 02-02, ahora bloqueado).
falsificado = bytearray(registro)
falsificado[20] ^= 1
try:
descifrar_historial(clave, "ana.perez", bytes(falsificado))
except HistorialManipulado as e:
print("BLOQUEADO:", e)
# Ataque 2: reetiquetar el registro como de otro paciente (AAD no coincide).
try:
descifrar_historial(clave, "otro.paciente", registro)
except HistorialManipulado as e:
print("BLOQUEADO:", e)Ambos ataques que en 02-02 tenían éxito ahora fallan ruidosamente. Con esto, la necesidad central del curso queda por fin cubierta: los historiales de MediNube se guardan cifrados con AES-256-GCM, con integridad, autenticidad y atados a su paciente, en un formato versionado listo para evolucionar.
Quedan dos cabos, y son deliberados:
- ¿De dónde sale la
clavede 32 bytes? En estos ejemplos la generamos conos.urandom(32), pero en un sistema real la clave debe provenir de algún sitio estable: una passphrase del administrador, una clave maestra de la que se derivan subclaves... Ese es exactamente el tema de la próxima lección, 02-04 (KDF). - ¿Cómo se guarda, protege y rota esa clave en producción? (gestores de secretos, HSM, rotación). Eso es materia del módulo 6; aquí solo lo señalamos.
Como siempre: datos ficticios. Un despliegue real con datos sanitarios exige, además de esta base criptográfica, revisión por profesionales de seguridad y cumplimiento normativo (RGPD).
Errores Comunes y Consejos
- Cifrar sin autenticar en 2026. Si usas CBC o CTR "a pelo" para datos que otros pueden tocar, tienes un bug. Por defecto: AEAD.
- Reutilizar el nonce en GCM. Aún más grave que en CTR: compromete la clave de autenticación y permite falsificar. Nonce aleatorio de 12 bytes por operación, sin excepciones.
- Ignorar la excepción de
decrypt. ElInvalidTag(o tuHistorialManipulado) no es un incordio, es la protección funcionando. Nunca lo captures para "seguir con los datos igualmente": si salta, los datos son inválidos y punto. - Meter secretos en el AAD. El AAD no se cifra, solo se autentica. Va en claro. Para el id de paciente y la versión es perfecto; para datos sensibles, nunca.
- Olvidar guardar el nonce. Debe almacenarse con el cifrado. Por eso lo incluimos en el propio registro
v1; no lo dejes "para luego". - Bajar a
hazmatsin necesidad. Si Fernet te vale (sin AAD, AES-128 aceptable), úsalo. Bajamos aAESGCMporque MediNube necesita AES-256, AAD y formato versionado — razones concretas, no por gusto.
Ejercicios
-
La red de seguridad. Usando
medinube.crypto, cifra un historial y luego, sobre elregistro, prueba tres manipulaciones: (a) voltear un byte del cifrado, (b) cambiar elid_pacienteal descifrar, (c) cambiar el byte de versión a0x02. Clasifica qué excepción lanza cada una y por qué. -
AAD que viaja en claro. Demuestra que el AAD no se cifra: cifra un historial con
aad=b"paciente=ana.perez;formato=v1"y comprueba que la subcadenab"ana.perez"no aparece en los bytes del cifrado, pero que aun así el registro está atado a ese paciente (descifrar con otro id falla). ¿Qué propiedad de AEAD ilustra esto? -
Fernet vs
v1. Cifra el mismo historial con Fernet y concifrar_historial_v1. Enumera tres diferencias concretas entre ambos resultados y justifica por qué MediNube eligió construirv1sobreAESGCMen lugar de usar Fernet.
Soluciones
-
(a) Voltear un byte del cifrado →
HistorialManipulado(internamenteInvalidTag): el tag recalculado no coincide con el guardado. (b) Cambiar elid_paciente→HistorialManipuladotambién: el AAD forma parte del cálculo del tag, así que un AAD distinto invalida la verificación. (c) Cambiar la versión a0x02→ValueError("Versión de formato desconocida: 2"): nuestrodescifrar_historialrechaza el registro antes de intentar descifrar, porque no sabe interpretar ese formato. Las tres protegen, pero por mecanismos distintos: (a) y (b) por el tag AEAD, (c) por la comprobación de versión del formato criptoágil.
import os from cryptography.hazmat.primitives.ciphers.aead import AESGCM aesgcm = AESGCM(os.urandom(32)); nonce = os.urandom(12) aad = b"paciente=ana.perez;formato=v1" cifrado = aesgcm.encrypt(nonce, b"ALERGIA: penicilina", aad) print(b"ana.perez" in cifrado) # False: el AAD no está DENTRO del cifrado
El id no aparece en el cifrado porque el AAD no se cifra ni se anexa: solo participa en el cálculo del tag. Aun así, descifrar con otro id falla (lo viste en el ejemplo). Ilustra la esencia de AEAD: se puede autenticar un dato sin cifrarlo — integridad y confidencialidad son propiedades independientes que AEAD combina a voluntad.
- Diferencias: (i) algoritmo — Fernet usa AES-128-CBC+HMAC;
v1usa AES-256-GCM (nuestro estándar de 256 bits). (ii) formato — Fernet devuelve un token Base64 autoconentido que no controlamos;v1es unbytesbinario con un byte de versión propio que gobernamos para la criptoagilidad. (iii) AAD — Fernet no admite datos asociados;v1ata el registro al paciente vía AAD. MediNube eligióv1porque necesita esas tres cosas concretas: AES-256, control del formato versionado (para migraciones futuras) y el vínculo autenticado con el paciente. Para un caso sin esos requisitos, Fernet habría sido la opción correcta y más simple.
Conclusión
Has cerrado el agujero que arrastrábamos desde 02-02 y saldado la deuda central del curso. El cifrado autenticado (AEAD) une confidencialidad, integridad y autenticidad en una operación que es difícil de usar mal: un tag que se recalcula al descifrar y hace fallar la operación ante cualquier manipulación de un solo bit. Dominas las dos construcciones estándar — AES-GCM (AESGCM) y ChaCha20-Poly1305 (ChaCha20Poly1305), de API idéntica y nonce de 12 bytes irrepetible —, sabes usar AAD para autenticar datos en claro como el id de paciente, y conoces Fernet como receta de alto nivel para cuando no necesitas más. Y, sobre todo, has construido medinube.crypto: los historiales de Ana Pérez se guardan ya cifrados en disco con AES-256-GCM en el formato versionado v1, resistiendo tanto la lectura como el bit-flipping y el reetiquetado. Solo falta responder una pregunta que hemos aplazado en cada ejemplo con un os.urandom(32) provisional: ¿de dónde sale esa clave maestra? Una contraseña de administrador no son 32 bytes de entropía pura, y no se puede meter una passphrase directamente como clave. La respuesta — convertir contraseñas en claves y derivar subclaves de una clave maestra — es la derivación de claves, y cierra el módulo en la próxima lección, 02-04: Derivación de Claves (KDF). 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
