La lección anterior terminó con una pregunta abierta sobre las claves públicas, pero también dejó una pieza a medias: al firmar un recibo de Nimbus no se firmaba el mensaje, se firmaba su hash. Esa primitiva lleva apareciendo desde el primer módulo —en la huella de las copias, en el HMAC del webhook de la pasarela, en el TOTP, en la tabla de credenciales— y hasta ahora nadie la ha explicado. Esta lección la desarrolla entera: qué es una función hash criptográfica y qué propiedades tiene, por qué MD5 y SHA-1 están rotos, por qué un hash a secas no autentica nada y de ahí nace el HMAC, y —el corazón de la lección y una deuda explícita del módulo 2— cómo debe guardar Nimbus las contraseñas de sus usuarios. Es probablemente la lección más directamente aplicable de todo el módulo: casi cualquier sistema que construyas guardará contraseñas, y hacerlo mal es la diferencia entre una filtración molesta y una catástrofe para miles de personas.

Contenido

  1. Qué es una función hash criptográfica
  2. Propiedades y efecto avalancha
  3. Familia de algoritmos: qué usar hoy y qué no
  4. Usos legítimos del hash
  5. Por qué un hash no autentica: del hash al MAC
  6. HMAC y el webhook de la pasarela de pago de Nimbus
  7. Por qué SHA-256 es inaceptable para contraseñas
  8. Sal y pepper
  9. Funciones de derivación lenta: Argon2id, scrypt, bcrypt y PBKDF2
  10. Registro y verificación de contraseñas en Nimbus
  11. Enumeración de usuarios y verificación en tiempo constante
  12. Qué hacer si se filtra la base de datos de credenciales
  13. Sumas de verificación no criptográficas

  1. Qué es una función hash criptográfica

Una función hash toma una entrada de cualquier tamaño y produce una salida de tamaño fijo, llamada resumen, digest o huella.

"hola"                         --SHA-256-->  32 bytes
Un fichero de 8 GB             --SHA-256-->  32 bytes
Una cadena vacia               --SHA-256-->  32 bytes

La clave está en el adjetivo criptográfica: cualquiera puede escribir una función que reduzca datos a un número fijo —eso es una tabla hash de toda la vida—, y lo que convierte a una en criptográfica son las garantías que ofrece frente a un adversario que intenta manipularla deliberadamente. Y hay una diferencia esencial con todo lo visto hasta ahora: una función hash no tiene clave. Cualquiera puede calcular el hash de cualquier cosa, lo que la hace útil para verificar integridad frente a errores y completamente insuficiente para autenticar frente a un atacante, como veremos en el apartado 5.


  1. Propiedades y efecto avalancha

Propiedad Qué significa Por qué importa
Determinista La misma entrada produce siempre la misma salida Sin esto no se puede verificar nada
Rápida de calcular Gigabytes por segundo Permite hashear ficheros grandes. Y es un problema para las contraseñas (apartado 7)
Unidireccional (resistencia a preimagen) Dado h, es inviable encontrar m tal que hash(m) = h Permite guardar huellas en lugar de datos: nadie invierte tu huella
Resistencia a segunda preimagen Dado m1, inviable encontrar m2 ≠ m1 con el mismo hash Nadie sustituye tu documento por otro que cuadre
Resistencia a colisión Inviable encontrar cualquier par m1 ≠ m2 con el mismo hash Es la propiedad más difícil de mantener y la primera que cae
Efecto avalancha Cambiar un bit de la entrada cambia ~la mitad de los bits de la salida Impide deducir nada sobre la entrada a partir de la salida

Las dos últimas merecen atención. La resistencia a colisión es más débil que la de segunda preimagen porque el atacante puede elegir los dos mensajes: por la paradoja del cumpleaños, con una salida de n bits se encuentran colisiones en unas 2^(n/2) operaciones, no 2^n. Por eso SHA-256, con 256 bits de salida, ofrece 128 bits de seguridad frente a colisiones. Y por eso SHA-1, con 160 bits, ofrecía solo 80 —y cayó—.

El efecto avalancha se ve mejor que se explica:

import hashlib

def sha256_hex(texto: str) -> str:
    return hashlib.sha256(texto.encode("utf-8")).hexdigest()

a = "Reserva 88421 confirmada para el 14/08/2026 a las 10:30"
b = "Reserva 88421 confirmada para el 14/08/2026 a las 10:31"   # UN caracter

ha, hb = sha256_hex(a), sha256_hex(b)
print("A:", ha)
print("B:", hb)

# Contamos cuantos BITS difieren entre los dos resumenes
ba = int(ha, 16)
bb = int(hb, 16)
distintos = bin(ba ^ bb).count("1")      # XOR: 1 donde los bits difieren
print(f"Bits distintos: {distintos} de 256 ({distintos / 256:.0%})")

Salida:

A: f145741afa9846807ae4254644e2b9e8393f9901e1f08c4136d335f72c29207f
B: 137b3357ce5e10b8d2e302f28f66c6af247b7b1e428ed45e9b5dca287cfd9145
Bits distintos: 134 de 256 (52%)

Lo que hay que entender: un solo carácter de diferencia —el minuto de la reserva— y los dos resúmenes no tienen ningún parecido. ba ^ bb hace un XOR entre los dos enteros, poniendo un 1 donde difieren, y contar los unos da la distancia de Hamming. El resultado ronda siempre el 50 %, que es lo que se espera de una salida indistinguible del azar: si un hash produjera un 5 % de cambio, revelaría información sobre la entrada y sería inservible. (hexdigest() devuelve 64 caracteres hexadecimales, que son los 32 bytes de salida; digest() devuelve los bytes crudos.)


  1. Familia de algoritmos: qué usar hoy y qué no

Algoritmo Salida Estado en 2026 ¿Usar hoy?
MD5 128 bits Roto. Colisiones en segundos en un portátil Nunca para seguridad
SHA-1 160 bits Roto. Colisión práctica demostrada (SHAttered, 2017) Nunca para seguridad
SHA-256 / SHA-512 256 / 512 bits Sólido. Familia SHA-2 Sí. Opción por defecto
SHA-3 (SHA3-256…) Variable Sólido, construcción distinta (esponja) Sí, cuando se quiere diversidad de diseño
BLAKE2 / BLAKE3 Variable Sólido y más rápido que SHA-2 Sí, para integridad de gran volumen
CRC32 32 bits No es criptográfico Solo detección de errores de transmisión (apartado 13)

Por qué MD5 y SHA-1 están rotos. No lo están en preimagen —nadie invierte un hash SHA-1—, sino en resistencia a colisión: se pueden construir dos documentos distintos con el mismo resumen. MD5 cayó en 2004 y hoy se generan colisiones en segundos; se demostró incluso la construcción de dos certificados digitales distintos con el mismo hash, lo que permitía suplantar identidades. SHA-1 cayó públicamente en 2017 con el ataque SHAttered, que produjo dos PDF distintos con el mismo resumen, y en 2020 los ataques de colisión con prefijo elegido bajaron el coste a decenas de miles de euros de cómputo, lo que llevó a su retirada definitiva de certificados y firmas.

Por qué una colisión es tan grave. Recuerda del apartado 8 de 03-03 que una firma se calcula sobre el hash. Si un atacante fabrica dos documentos con el mismo resumen —uno inocuo, otro malicioso—, consigue que le firmes el inocuo y esa misma firma es válida para el malicioso. La firma es correcta; el hash es el que traicionó.

Aviso práctico: MD5 y SHA-1 siguen apareciendo en código heredado para cosas que no son seguridad —claves de caché, deduplicación interna, identificadores de recursos—. En esos usos no hay riesgo, pero conviene documentarlo explícitamente para que un auditor no lo señale y, sobre todo, para que nadie los reutilice después con intención de seguridad.


  1. Usos legítimos del hash

Uso Ejemplo en Nimbus
Verificar integridad frente a errores Comprobar que la copia nocturna se transfirió completa
Huella de fichero y deduplicación Identificar sin comparar byte a byte; no guardar dos veces el mismo adjunto
Índice de datos cifrados Buscar sin descifrar, con HMAC determinista (03-07; requiere clave)
Base de firmas y MAC Lo que se firma es el hash (03-03 y apartado 6)
Identificar versiones Cada commit de Git es un hash

El caso más cotidiano para Lucía es verificar una copia:

# 1. Al crear la copia, se genera su huella
sha256sum copia-2026-08-02.tar.gz > copia-2026-08-02.tar.gz.sha256

# 2. Meses despues, tras descargarla del almacenamiento externo,
#    se verifica ANTES de intentar restaurarla
sha256sum -c copia-2026-08-02.tar.gz.sha256
9f4c1a7d3e0b8256cf10a94b7d3e6f28c0574ab19e83d2f6410c9b7e5a2d8f31  copia-2026-08-02.tar.gz
copia-2026-08-02.tar.gz: La suma coincide

sha256sum -c lee el fichero de huellas y recalcula el hash del archivo real; si no coincide, imprime LA SUMA NO COINCIDE y devuelve un código de salida distinto de cero, lo que permite automatizar la comprobación dentro del script de restauración. Esto detecta corrupción —transferencia truncada, disco con sectores dañados, fichero mal descomprimido— y es exactamente el «0» del esquema 3-2-1-1-0 de 02-04, cero errores de verificación. Lo que no detecta es un ataque, y ese es el punto que enlaza con el apartado siguiente.


  1. Por qué un hash no autentica: del hash al MAC

Vuelve al ejemplo anterior con ojos de atacante. Un intruso con acceso de escritura al almacenamiento de copias sustituye copia-2026-08-02.tar.gz por una versión manipulada, ejecuta sha256sum sobre su versión y sobrescribe el fichero .sha256 con el nuevo resultado. La verificación de Lucía dirá «la suma coincide». Y coincidirá. El fallo no está en SHA-256: está en que el hash no tiene clave, así que cualquiera puede recalcularlo.

La regla: un hash protege frente a accidentes; un MAC o una firma protegen frente a adversarios. Si el atacante puede modificar el dato, también puede modificar su hash.

Las dos soluciones ya las conoces: el MAC (HMAC), con clave compartida, cuando las dos partes se conocen y no hace falta prueba ante terceros —lo que desarrolla este apartado—; y la firma digital, con par asimétrico, cuando el verificador puede ser cualquiera o hace falta no repudio (03-03).

Por qué no basta con hash(clave || mensaje)

La construcción ingenua consiste en concatenar la clave delante del mensaje y hashear. Parece razonable: sin la clave no se puede calcular. Y es insegura con las funciones de la familia SHA-2, por el ataque de extensión de longitud.

La razón está en cómo funcionan internamente: SHA-256 procesa el mensaje por bloques y su salida es su estado interno final. Un atacante que conoce hash(clave || mensaje) y la longitud de la clave puede retomar la función desde ese estado y calcular hash(clave || mensaje || relleno || datos_añadidos) sin conocer la clave. Obtiene un MAC válido para un mensaje que él ha extendido.

Traducido a Nimbus: si el esquema de las URLs firmadas usara esa construcción, un atacante podría partir de una URL firmada legítima y añadirle parámetros —por ejemplo, ampliar la caducidad o cambiar el alcance—, generando una firma válida sin la clave.

HMAC existe precisamente para eso. Su construcción usa la clave dos veces, en dos pasadas anidadas:

HMAC(K, m) = H( (K XOR opad) || H( (K XOR ipad) || m ) )

La pasada exterior impide reconstruir el estado interno, y con ello el ataque de extensión. No la implementes tú: hmac.new(...) en Python ya lo hace.


  1. HMAC y el webhook de la pasarela de pago de Nimbus

La pasarela de pago notifica a Nimbus cuando un cobro se completa, mediante una petición HTTP a un extremo público. Sin verificación, cualquiera que descubra esa URL podría enviar notificaciones falsas de pago —lo que, en un SaaS de reservas, se traduce en citas confirmadas sin cobrar—. La pasarela y Nimbus comparten un secreto y firman cada notificación con HMAC-SHA256.

import hmac, hashlib, time
from fastapi import APIRouter, Request, HTTPException

router = APIRouter()
SECRETO_WEBHOOK: bytes = obtener_secreto("pasarela/webhook")   # gestor (03-06)
TOLERANCIA_SEGUNDOS = 300                                       # 5 minutos

def firma_esperada(cuerpo: bytes, marca: str) -> str:
    """Se firma marca-de-tiempo + cuerpo CRUDO, no el JSON reserializado."""
    return hmac.new(SECRETO_WEBHOOK,
                    marca.encode("ascii") + b"." + cuerpo,
                    hashlib.sha256).hexdigest()

@router.post("/webhooks/pasarela")
async def recibir_webhook(peticion: Request):
    cuerpo = await peticion.body()          # 1. cuerpo CRUDO, tal cual llego
    marca = peticion.headers.get("X-Pasarela-Timestamp", "")
    recibida = peticion.headers.get("X-Pasarela-Signature", "")
    if not marca or not recibida:
        raise HTTPException(400, "Faltan cabeceras de firma")

    # 2. FRESCURA: se rechazan las notificaciones viejas (anti-repeticion)
    try:
        if abs(time.time() - int(marca)) > TOLERANCIA_SEGUNDOS:
            raise HTTPException(400, "Notificacion caducada")
    except ValueError:
        raise HTTPException(400, "Marca de tiempo invalida")

    # 3. COMPARACION EN TIEMPO CONSTANTE
    if not hmac.compare_digest(firma_esperada(cuerpo, marca), recibida):
        registrar_evento("webhook.firma_invalida", ip=peticion.client.host)
        raise HTTPException(401, "Firma invalida")

    # 4. IDEMPOTENCIA: la misma notificacion puede llegar dos veces
    evento = parsear(cuerpo)
    if ya_procesado(evento["id"]):
        return {"estado": "duplicado_ignorado"}
    procesar_pago(evento)
    return {"estado": "ok"}

Análisis de las decisiones, que son todas deliberadas:

  • Se firma el cuerpo crudo (await peticion.body()), no el diccionario reserializado. Si parseas el JSON y lo vuelves a serializar, cualquier diferencia de espaciado o de orden de claves cambia los bytes y la firma falla. Es el mismo problema de canonicalización de 03-03, y la solución es firmar exactamente lo que viajó por la red.
  • Se firma también la marca de tiempo, unida al cuerpo con un separador. Si solo se firmara el cuerpo, un atacante podría capturar una notificación legítima y reenviarla mañana con la firma intacta. Esa es la propiedad de frescura de 03-01, y la ventana de cinco minutos es lo que la garantiza; el mismo razonamiento que hay detrás de la ventana de 30 segundos del TOTP de 02-05.
  • hmac.compare_digest compara en tiempo constante: tarda lo mismo acierte el primer carácter o el último. Con ==, Python cortaría en la primera diferencia y un atacante que midiera miles de intentos reconstruiría la firma carácter a carácter (03-01). Esta línea, sola, es la diferencia entre un webhook seguro y uno vulnerable. Ojo: los dos argumentos deben ser del mismo tipo (aquí, hexdigest() en ambos lados).
  • Se registra el intento fallido, con la IP y sin volcar el cuerpo (02-04): una racha de firmas inválidas es una alerta. Y la idempotencia, que no es criptografía pero sí imprescindible, porque las pasarelas reintentan y procesar dos veces el mismo pago es un incidente de negocio.

Este mismo patrón —HMAC sobre un mensaje que incluye una caducidad— es exactamente el que hay detrás de las URL firmadas de 120 segundos del bucket A-05 que llevamos citando desde el módulo 1. Se desmonta por dentro en 03-07.


  1. Por qué SHA-256 es inaceptable para contraseñas

Llegamos al corazón de la lección. Nimbus guarda credenciales de sus usuarios, y esas personas —con toda seguridad— reutilizan sus contraseñas en otros servicios. Guardarlas mal no solo compromete Nimbus: compromete el correo, el banco y la vida digital de miles de pacientes y profesionales.

Empecemos por lo que nunca se hace:

Guardarlas en claro es indefendible: cualquier filtración, copia de seguridad o SELECT las expone. Cifrarlas tampoco vale, porque existe una clave que las descifra y quien roba la base de datos suele poder robar también la clave —y además nunca hace falta recuperar una contraseña, solo verificarla—. Y md5(contraseña) suma a todo lo anterior un algoritmo roto.

El problema con sha256(contraseña) es que SHA-256 es rápido. Esa virtud, que lo hace excelente para verificar una copia de 8 GB, es exactamente el defecto que lo inhabilita aquí:

Ataque Coste con SHA-256 a secas
Fuerza bruta con GPU Una GPU moderna calcula del orden de 10.000 millones de SHA-256 por segundo. Un diccionario de las 10.000 millones de contraseñas más comunes se agota en un segundo
Tablas rainbow Tablas precalculadas que invierten hashes comunes al instante, disponibles públicamente
Hashes idénticos Sin sal, dos usuarios con la misma contraseña tienen el mismo hash: se ve de un vistazo quién comparte contraseña, y romper una rompe todas

Un cálculo que ayuda a interiorizarlo: una contraseña de ocho caracteres alfanuméricos tiene unas 218 billones de combinaciones, que a 10.000 millones por segundo se agotan en menos de seis horas con una sola GPU; con Argon2id bien calibrado, esa misma búsqueda pasaría de horas a siglos, porque cada intento cuesta cientos de miles de veces más. De aquí salen las tres piezas de la solución: sal (que mata las tablas precalculadas y los hashes idénticos), lentitud deliberada (que encarece cada intento) y coste de memoria (que anula la ventaja de las GPU).


  1. Sal y pepper

La sal (salt) es un valor aleatorio, único por usuario, que se combina con la contraseña antes de derivar el hash y se guarda junto a él, en claro.

Las cuatro preguntas que siempre surgen: no es secreta (se guarda al lado del hash y no pasa nada); no puede ser la misma para todos ni derivarse del correo o del ID, porque debe ser aleatoria e impredecible y los correos se repiten entre servicios; 16 bytes de secrets es el tamaño estándar; y se guarda dentro de la propia cadena del hash, porque los formatos modernos ya la incluyen. Qué consigue exactamente: anula las tablas rainbow (una tabla precalculada solo sirve para una sal concreta, así que habría que construir una por usuario), rompe la igualdad de hashes (dos usuarios con la contraseña Valencia2026 tienen hashes completamente distintos) y obliga a atacar de uno en uno, porque sin sal un atacante prueba cada candidato contra los 40.000 usuarios a la vez.

El pepper (pimienta) es un secreto global de la aplicación que se añade además de la sal, y cuya diferencia crucial es dónde vive:

Sal Pepper
Alcance y secreto Única por usuario, no secreta Única para la aplicación, secreta
Dónde vive En la base de datos, junto al hash Fuera de la base de datos: gestor de secretos, HSM o variable del proceso
Qué aporta Anula tablas y colisiones Si roban solo la base de datos, los hashes son inatacables

El pepper es una capa de defensa en profundidad valiosa —cubre exactamente el escenario más frecuente, el volcado de base de datos por una inyección SQL— pero tiene un coste real: rotarlo obliga a que todos los usuarios cambien su contraseña o a un esquema de doble verificación, porque no se puede recalcular sin las contraseñas originales. Para Nimbus, la recomendación es implantar primero Argon2id correctamente y valorar el pepper después, en forma de HMAC aplicado a la contraseña antes de derivarla, con la clave en el gestor de secretos.


  1. Funciones de derivación lenta: Argon2id, scrypt, bcrypt y PBKDF2

Estas funciones se llaman KDF de contraseña (password-based key derivation function) y su diseño es deliberadamente caro. Es la distinción que dejamos anunciada en 03-02: HKDF deriva claves a partir de claves y es rápido; estas derivan a partir de secretos de baja entropía y deben ser lentas.

Algoritmo Coste de memoria Parámetros de partida en 2026 Recomendación
Argon2id Sí, configurable memoria 19 MiB, tiempo 2, paralelismo 1 (o 46 MiB / 1 / 1) Primera opción. Ganador del Password Hashing Competition
scrypt N = 2^17, r = 8, p = 1 Buena alternativa si no hay Argon2
bcrypt Poco (4 KB fijos) coste 12 Aceptable. Limitado a 72 bytes de entrada
PBKDF2-HMAC-SHA256 No 600.000 iteraciones Solo si una norma lo exige (por ejemplo FIPS). El más débil frente a GPU

Comprueba siempre la guía vigente de OWASP, porque el hardware avanza. La clave está en el coste de memoria: una GPU tiene miles de núcleos pero poca memoria por núcleo, así que un algoritmo que exige 19 MB por cálculo impide que esos miles de núcleos trabajen en paralelo y la ventaja del hardware especializado se desvanece. PBKDF2 solo exige tiempo de CPU, que es justo lo que las GPU y los ASIC hacen barato.

Cómo calibrarlos. El criterio no es memorizar números, es medir en tu hardware: fija un presupuesto de tiempo por verificación de entre 250 y 500 ms en producción (menos deja demasiado margen al atacante, más degrada la experiencia y abre una vía de denegación de servicio); sube primero la memoria, que es lo que penaliza a las GPU, y después el tiempo; mide con la concurrencia real, porque 20 inicios de sesión simultáneos con 46 MiB cada uno son casi 1 GB de RAM en picos, y eso es un parámetro de capacidad además de seguridad; y anota valores y fecha para revisarlos cada año, sabiendo que el rehash transparente del apartado siguiente hace que subirlos no cueste nada.


  1. Registro y verificación de contraseñas en Nimbus

Esquema de la tabla de credenciales. Fíjate en lo que no contiene:

CREATE TABLE credenciales (
    usuario_id       BIGINT      PRIMARY KEY REFERENCES usuarios(id),
    -- Cadena PHC completa: incluye algoritmo, parametros, sal y hash.
    -- No hay columna "sal" separada: ya viaja dentro.
    hash_password    TEXT        NOT NULL,
    actualizado_en   TIMESTAMPTZ NOT NULL DEFAULT now(),
    intentos_fallidos SMALLINT   NOT NULL DEFAULT 0,   -- fuerza bruta (02-05)
    bloqueado_hasta  TIMESTAMPTZ
);

-- El rol de informes (01-04) no tiene nada que hacer aqui:
REVOKE ALL ON credenciales FROM nimbus_informes;
GRANT SELECT, UPDATE ON credenciales TO nimbus_api;

Una cadena PHC de Argon2id tiene este aspecto:

$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$3xTk9pQmR0aVdlbGxEb25lSGFzaFZhbHVl
 algoritmo  ver  parametros      SAL aleatoria      hash derivado
                 (19456 KiB, 2 pasadas, 1 hilo)     (ambos en Base64)

Todo lo necesario para verificar está en esa cadena, y por eso no hace falta una columna de sal ni una de parámetros: cuando cambies los parámetros, los hashes antiguos seguirán verificándose con los suyos. El código, con la librería argon2-cffi, que es la implementación de referencia en Python:

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError

# Parametros centralizados en UN solo sitio (apartado 9)
ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)

# Hash "senuelo": se usa cuando el usuario NO existe, para que la respuesta
# tarde lo mismo. Se calcula una vez al arrancar el proceso.
HASH_SENUELO = ph.hash("contrasenia-que-nadie-usara-jamas")

def registrar(usuario_id: int, password: str, repo) -> None:
    if len(password) < 12:                     # politica (02-05), ANTES de derivar
        raise ValueError("La contrasenia debe tener al menos 12 caracteres")
    repo.guardar_hash(usuario_id, ph.hash(password))   # la sal la genera la libreria

def verificar(email: str, password: str, repo) -> bool:
    usuario = repo.buscar_por_email(email)
    # Si el usuario no existe se verifica contra el senuelo: coste identico,
    # el atacante no puede distinguir (apartado 11).
    almacenado = usuario.hash_password if usuario else HASH_SENUELO
    try:
        ph.verify(almacenado, password)
    except (VerifyMismatchError, VerificationError, InvalidHashError):
        return False
    if usuario is None:                        # era el senuelo: siempre falla
        return False
    # REHASH TRANSPARENTE: si los parametros han cambiado, se recalcula ahora
    # que tenemos la contrasenia en claro (unico momento posible).
    if ph.check_needs_rehash(almacenado):
        repo.guardar_hash(usuario.id, ph.hash(password))
    return True

Explicación de las decisiones no evidentes:

  • ph.hash(password) genera la sal internamente con una fuente segura y devuelve la cadena PHC completa. No hay ninguna decisión que puedas tomar mal.
  • ph.verify lanza excepción en lugar de devolver False. Mismo criterio de diseño que verify en Ed25519 (03-03): obliga a tratar el fallo. VerifyMismatchError es contraseña incorrecta; InvalidHashError significa que la cadena almacenada está corrupta o es de otro formato, y merece una alerta.
  • El hash señuelo es la pieza del apartado 11. Sin él, la respuesta para un usuario inexistente sería inmediata y para uno existente tardaría 300 ms: esa diferencia es la enumeración de usuarios.
  • check_needs_rehash compara los parámetros de la cadena almacenada con los actuales. Si has subido la memoria de 19 a 46 MiB, devuelve True y el hash se actualiza en el único instante en que dispones de la contraseña en claro. Con este mecanismo, endurecer los parámetros no requiere migración ni molestar a nadie: la base de usuarios se actualiza sola conforme la gente entra.
  • La política de longitud se comprueba antes de derivar, para no gastar 300 ms de CPU en una entrada ya inválida; y hay que poner también un máximo razonable (128 caracteres), porque sin él alguien podría enviar 10 MB de contraseña y provocar una denegación de servicio. Y nunca registres la contraseña, ni siquiera en un logger.debug temporal: es el error de 02-04 y sobrevive en los logs mucho más de lo que nadie espera.

  1. Enumeración de usuarios y verificación en tiempo constante

Un sistema enumera usuarios cuando permite distinguir si una cuenta existe. Parece menor y no lo es: es el paso previo del password spraying y del phishing dirigido de 02-03. Saber que [email protected] existe convierte un ataque genérico en uno personalizado.

Las cuatro fugas por las que se filtra esa información:

Fuga Síntoma Corrección
Mensaje distinto «Usuario no encontrado» frente a «Contraseña incorrecta» Mensaje único: «Credenciales incorrectas»
Tiempo distinto 5 ms si no existe, 300 ms si existe Hash señuelo (apartado 10)
Código o estructura de respuesta 404 frente a 401, o campos distintos Respuesta idéntica
Registro y recuperación «Ese correo ya está registrado» «Si el correo está disponible, recibirás un mensaje»

La del tiempo es la más olvidada porque no se ve leyendo el código: hay que medirla, cronometrando cien intentos con un correo existente y cien con uno inventado; si las medias difieren de forma consistente, hay fuga.

Y no confundas dos cosas que se parecen: el tiempo constante en la comparación (hmac.compare_digest) evita reconstruir un secreto carácter a carácter, mientras que el tiempo constante en la respuesta global (hash señuelo) evita distinguir si una cuenta existe. Son defensas distintas contra ataques distintos y hacen falta las dos. Un matiz honesto: la igualdad perfecta de tiempos es inalcanzable en un sistema real con cachés y red; el objetivo es que la diferencia quede muy por debajo del ruido de medición, no que sea cero.


  1. Qué hacer si se filtra la base de datos de credenciales

Escenario: Lucía descubre que un volcado de la tabla credenciales ha salido de la infraestructura. Con Argon2id bien calibrado, las contraseñas no son inmediatamente utilizables, pero eso no significa que no haya nada que hacer.

Primeras horas: (1) contener y preservar —cortar el acceso usado, conservar evidencia y registros, según el plan de respuesta de 04-05—; (2) determinar el alcance exacto: cuántos usuarios, qué otras tablas, qué ventana temporal; (3) invalidar todas las sesiones y tokens activos, porque una sesión válida no necesita contraseña; (4) forzar el restablecimiento por un flujo que no dependa de la contraseña antigua.

Días siguientes: (5) rotar el pepper, si existe, y cualquier secreto que estuviera en la misma base de datos; (6) notificar en plazo; (7) endurecer los parámetros de Argon2id y forzar el rehash; (8) hacer el análisis de causa raíz con el método de 02-06: no se busca culpable, se busca por qué el control no estuvo.

Nota de validación profesional. El RGPD obliga a notificar a la autoridad de control en un plazo máximo de 72 horas desde el conocimiento de la brecha cuando exista riesgo para los derechos de los afectados, y a comunicárselo a estos si el riesgo es alto. Como los datos de Nimbus incluyen información que revela indirectamente el estado de salud, el análisis debe hacerse con el delegado o responsable de protección de datos y con asesoría legal. El detalle normativo se trata en 06-03.

Lo que NO hay que hacer: minimizar públicamente («los hashes están cifrados, no hay riesgo»), retrasar la notificación esperando el informe completo, o dar por seguro que los usuarios no reutilizan esas contraseñas en otros servicios. Lo hacen. Ese es el motivo real por el que Argon2id importa.


  1. Sumas de verificación no criptográficas

CRC32, Adler-32 y los dígitos de control de un IBAN o un DNI son sumas de verificación, no hashes criptográficos. Su propósito es detectar errores accidentales de transmisión o de tecleo —y para eso son excelentes—, pero están diseñados frente al ruido, no frente a un adversario: CRC32 produce 32 bits de salida y construir una colisión a voluntad es trivial, mientras que en SHA-256, con 256 bits, es inviable. Uso correcto de CRC32: Ethernet, ZIP, PNG, detección de corrupción. Uso correcto de SHA-256: integridad, firmas y huellas.

El error a evitar es usar CRC32 donde hay un adversario: como identificador «único» de un adjunto, como comprobación de que un fichero descargado no ha sido manipulado, o como parte de un esquema de autenticación. Un atacante construye una colisión CRC32 en milisegundos y en el sentido que quiera. La regla completa, para cerrar: frente a errores accidentales, CRC32 o SHA-256; frente a un adversario que modifica datos, HMAC o firma; frente a un adversario que roba la base de datos de contraseñas, Argon2id con sal única.


Errores Comunes y Consejos

Sobre hashes y MAC

  1. Usar MD5 o SHA-1 para seguridad. Están rotos en colisión, y una colisión rompe cualquier firma construida sobre ellos. Lo mismo vale para CRC32 donde hay adversario.
  2. Creer que un hash publicado junto al fichero protege de un atacante. Solo protege de errores: quien puede cambiar el fichero puede cambiar el hash.
  3. Construir un MAC como hash(clave || mensaje). Vulnerable a extensión de longitud. Usa HMAC.
  4. Comparar firmas con ==. Canal lateral de temporización. Siempre hmac.compare_digest.
  5. Firmar el JSON reserializado en lugar del cuerpo crudo. Produce fallos intermitentes que parecen ataques.
  6. No incluir marca de tiempo ni comprobar frescura. Sin eso, un mensaje legítimo capturado hoy se reenvía dentro de un mes.

Sobre contraseñas

  1. sha256(contraseña). Diez mil millones de intentos por segundo con una GPU.
  2. Sal fija, compartida o derivada del correo. Debe ser aleatoria y única por usuario; deja que la librería la genere.
  3. Implementar Argon2 a mano o copiar un fragmento de un foro. Usa argon2-cffi o el Argon2id de cryptography.
  4. Mensajes distintos para «no existe» y «contraseña incorrecta», u olvidar el hash señuelo. Las dos vías de enumeración de usuarios; la segunda no se ve leyendo el código.
  5. No poner longitud máxima. Vía abierta a denegación de servicio.
  6. Fijar los parámetros y no volver a mirarlos en cinco años. Usa check_needs_rehash y revísalos anualmente.

Consejos

  • Centraliza el hashing de contraseñas en una única función de la base de código. Ninguna otra parte del sistema debe llamar a ph.hash directamente.
  • Añade una comprobación en el CI (02-04) que rechace hashlib.md5, hashlib.sha1 y sha256( aplicado a algo llamado password, y registra métricas de tiempo medio de verificación: si sube, hay un parámetro mal calibrado; si baja de golpe, alguien ha rebajado el coste.
  • Recuerda la jerarquía: hash para errores, MAC o firma para adversarios, KDF lenta para contraseñas. Elegir bien entre las tres resuelve la mayoría de los problemas de esta lección.

Ejercicios

Ejercicio 1 — Auditar la verificación de un webhook

Nimbus recibe también notificaciones del proveedor de email transaccional. Este es el código:

import hashlib

SECRETO = "nimbus-email-2026"

@app.post("/webhooks/email")
async def recibir(peticion: Request):
    datos = await peticion.json()                                     # (1)
    firma = peticion.headers.get("X-Signature", "")
    calculada = hashlib.sha256(
        (SECRETO + json.dumps(datos)).encode()                        # (2)
    ).hexdigest()
    if calculada != firma:                                            # (3)
        raise HTTPException(401)
    procesar(datos)                                                   # (4)
    return {"ok": True}

Se pide: (a) identifica los cuatro problemas marcados y el ataque concreto que habilita cada uno; (b) explica por qué el problema (2) es un fallo criptográfico y no solo de estilo; (c) reescribe el endpoint correctamente; (d) indica qué debe registrarse y qué no debe registrarse en este flujo.

Ejercicio 2 — Migrar el almacenamiento de contraseñas

Nimbus tiene 41.000 credenciales guardadas como sha256(password) sin sal. Marta quiere migrar a Argon2id sin obligar a todo el mundo a cambiar la contraseña de golpe.

Se pide: (a) explica por qué no se puede convertir directamente un hash SHA-256 en un hash Argon2id; (b) diseña una estrategia de migración progresiva, indicando qué columna añadirías, qué ocurre en el siguiente inicio de sesión de cada usuario y qué pasa con quien no entra nunca; (c) escribe el pseudocódigo de la verificación durante la migración; (d) decide qué hacer con las cuentas que sigan en SHA-256 pasados seis meses y justifícalo; (e) indica si esta situación debe tratarse como incidente de seguridad y por qué.

Ejercicio 3 — Calibrar y defender los parámetros

Iván propone PasswordHasher(memory_cost=8, time_cost=1, parallelism=1) porque «así el login va instantáneo».

Se pide: (a) explica qué significa cada parámetro y qué implica ese valor de memoria; (b) estima cualitativamente cuánto se abarata el trabajo del atacante frente a los parámetros recomendados; (c) describe el procedimiento que seguirías para calibrar los parámetros reales en el servidor de Nimbus, incluyendo qué medirías y con qué concurrencia; (d) formula la respuesta que darías a Iván y a Marta, incluyendo el compromiso entre experiencia de usuario, capacidad del servidor y riesgo de denegación de servicio.


Soluciones

Ejercicio 1

(a) Los cuatro problemas:

# Problema Ataque
1 Se parsea el JSON y luego se reserializa para firmar Cualquier diferencia de espaciado, orden de claves o codificación produce bytes distintos: la firma legítima falla y se generan falsos rechazos. Y si se «arregla» relajando la comparación, se abre la puerta a manipulaciones
2 sha256(SECRETO + mensaje) Extensión de longitud: un atacante que capture un mensaje firmado puede añadirle datos al final y calcular una firma válida sin conocer el secreto. Además, el secreto está en el código
3 Comparación con != Canal lateral de temporización: se reconstruye la firma carácter a carácter midiendo tiempos
4 Sin marca de tiempo, sin frescura y sin idempotencia Repetición: capturar una notificación legítima y reenviarla mañana, tantas veces como se quiera

(b) Porque no es una cuestión de gusto sino de la construcción interna de SHA-2: su salida es su estado interno, así que retomarlo permite continuar el cálculo. HMAC no es «lo mismo pero con más pasos»: su segunda pasada es precisamente lo que impide ese ataque.

(c) Versión correcta. Es el patrón del apartado 6, aplicado a este proveedor:

import hmac, hashlib, time, json

SECRETO = obtener_secreto("email/webhook")      # bytes, del gestor (03-06)

@app.post("/webhooks/email")
async def recibir(peticion: Request):
    cuerpo = await peticion.body()                       # bytes CRUDOS
    marca = peticion.headers.get("X-Timestamp", "")
    firma = peticion.headers.get("X-Signature", "")
    if not marca or not firma or abs(time.time() - int(marca)) > 300:
        raise HTTPException(400, "Cabeceras ausentes o caducadas")

    mensaje = marca.encode() + b"." + cuerpo
    esperada = hmac.new(SECRETO, mensaje, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(esperada, firma):
        registrar_evento("webhook.email.firma_invalida", ip=peticion.client.host)
        raise HTTPException(401, "Firma invalida")

    evento = json.loads(cuerpo)
    if ya_procesado(evento["id"]):
        return {"ok": True, "estado": "duplicado"}
    procesar(evento)
    return {"ok": True}

(d) Registro. Sí: identificador del evento, tipo, marca de tiempo, IP de origen, resultado (aceptado/rechazado) y motivo del rechazo. No: el cuerpo completo (contiene correos y posiblemente contenido del mensaje), la firma recibida completa, el secreto, ni ninguna cabecera de autenticación. Es la regla de 02-04: registrar lo suficiente para detectar y para investigar, sin convertir el log en una segunda copia de los datos personales.

Ejercicio 2

(a) Porque un hash es unidireccional: de sha256(password) no se puede recuperar la contraseña, y Argon2id necesita la contraseña en claro para derivar su propio hash. La única conversión posible sería argon2id(sha256(password)) —hashear el hash—, que funciona pero deja el sistema atado indefinidamente a una capa SHA-256 sin sal, complica la lógica y no es la mejor opción si se puede migrar de forma limpia.

(b) Estrategia progresiva. Detectar el formato por el prefijo $argon2id$ de la propia cadena (o añadir una columna algoritmo). En el siguiente inicio de sesión correcto de cada usuario se dispone de la contraseña en claro: se verifica con el esquema antiguo y, si es correcta, se recalcula con Argon2id y se sustituye, sin que el usuario note nada. Quien no entra nunca sigue con el hash antiguo, y por eso hace falta el paso (d).

(c) Pseudocódigo:

def verificar(email, password, repo):
    u = repo.buscar_por_email(email)
    almacenado = u.hash_password if u else HASH_SENUELO
    if almacenado.startswith("$argon2id$"):
        ok = argon2_verifica(almacenado, password)
        if ok and u and ph.check_needs_rehash(almacenado):
            repo.guardar_hash(u.id, ph.hash(password))
    else:
        # Camino heredado: comparacion en tiempo constante del hash antiguo
        ok = hmac.compare_digest(
            hashlib.sha256(password.encode()).hexdigest(), almacenado
        )
        if ok and u:
            repo.guardar_hash(u.id, ph.hash(password))   # MIGRACION
    return bool(ok and u)

(d) A los seis meses. Las cuentas que sigan en SHA-256 son, casi por definición, cuentas inactivas: un riesgo doble (hash débil y cuenta huérfana de 02-05). Lo correcto es forzar el restablecimiento de contraseña en esas cuentas —invalidándolas hasta que el usuario complete el flujo— y, para las que llevan más de un año sin actividad, evaluar su desactivación conforme a la política de ciclo de vida. Mantener indefinidamente hashes SHA-256 sin sal es aceptar un riesgo conocido sin justificación.

(e) ¿Es un incidente? No es una brecha —no ha salido nada—, pero sí es un hallazgo de seguridad grave que debe registrarse en el inventario de riesgos, con dueño y plazo, y tratarse como tal (módulo 4). Si además existiera cualquier indicio de que esa tabla se ha copiado alguna vez, pasaría a ser incidente y se aplicaría el apartado 12, incluida la valoración de notificación con asesoría legal.

Ejercicio 3

(a) memory_cost está en KiB: 8 KiB, es decir, ocho kilobytes, frente a los 19.456 KiB (19 MiB) recomendados; unas 2.400 veces menos. time_cost=1 es una sola pasada, y parallelism=1 un hilo. Con 8 KiB, Argon2id pierde por completo su defensa característica: cabe holgadamente en la caché de cada núcleo de una GPU y se vuelve comparable a un hash rápido.

(b) El coste por intento se reduce en al menos tres órdenes de magnitud, y la ventaja del hardware paralelo se restaura por completo. Una búsqueda que con parámetros correctos costaría siglos vuelve al orden de días o semanas para una contraseña mediana. Cualitativamente: la configuración de Iván tiene el nombre de Argon2id y la seguridad de un hash rápido con sal.

(c) Procedimiento de calibración. Medir en el hardware de producción, no en el portátil de desarrollo; partir de memoria 19 MiB, tiempo 2, paralelismo 1 y cronometrar ph.hash() cien veces; ajustar hasta situar la mediana entre 250 y 500 ms, subiendo primero la memoria; repetir la medición con la concurrencia máxima esperada de inicios de sesión simultáneos, vigilando RAM y latencia del resto de la API; y documentar valores, fecha, hardware y percentil 95, con revisión anual programada.

(d) Respuesta a Iván y Marta. El inicio de sesión no necesita ser instantáneo: 300 ms son imperceptibles para una persona que acaba de teclear su contraseña, y ocurren una vez por sesión, no en cada petición —el resto del tiempo la autenticación la resuelve el token de 02-05—. Ese cuarto de segundo es exactamente lo que multiplica por millones el coste del atacante si algún día se filtra la tabla. El compromiso real no es contra la experiencia de usuario, sino contra la capacidad del servidor: hay que dimensionar la RAM para el pico de inicios simultáneos y combinar la medida con límite de tasa y bloqueo progresivo (02-05) para que nadie pueda usar el propio coste del hash como vector de denegación de servicio.


Conclusión

Has cubierto la primitiva que faltaba y saldado la deuda que el módulo 2 dejó pendiente. Sabes qué es una función hash criptográfica —salida fija, determinista, unidireccional— y sus tres resistencias, con la de colisión como la más frágil y la primera en caer; has visto el efecto avalancha medido en bits y por qué el 50 % es la cifra correcta. Conoces el estado real de la familia: MD5 y SHA-1 rotos en colisión, con SHAttered como referencia, y por qué una colisión destruye cualquier firma construida encima; SHA-256/512, SHA-3 y BLAKE2 como opciones vigentes.

Y has aprendido la frontera que separa dos mundos: un hash protege frente a accidentes; un MAC o una firma protegen frente a adversarios, porque quien puede alterar el fichero también puede recalcular su sha256sum. De ahí el HMAC, con el porqué exacto de que hash(clave || mensaje) sea inseguro —el ataque de extensión de longitud—, aplicado al webhook de la pasarela de pago con sus cuatro decisiones deliberadas: firmar el cuerpo crudo, incluir la marca de tiempo para dar frescura, comparar con hmac.compare_digest y ser idempotente. Y cierras con la distinción entre sumas de verificación como CRC32 y hashes criptográficos.

El corazón de la lección era el almacenamiento de contraseñas, y ahí está la respuesta completa que Nimbus necesitaba: sha256(contraseña) es inaceptable porque es rápido —diez mil millones de intentos por segundo con una sola GPU—, la sal única por usuario anula las tablas precalculadas y obliga a atacar de uno en uno, el pepper vive fuera de la base de datos y cubre el volcado por inyección SQL, y la solución es una función de derivación lenta con coste de memoria: Argon2id como primera opción, con 19 MiB, dos pasadas y un hilo como punto de partida, calibrados hasta 250–500 ms en el hardware real. Lo has implementado entero: cadena PHC con la sal dentro, tabla credenciales, hash señuelo para que la respuesta tarde lo mismo exista o no el usuario, y rehash transparente con check_needs_rehash para endurecer parámetros sin migraciones ni molestias, además del plan de qué hacer si la tabla se filtra —contener, invalidar sesiones, forzar restablecimiento y notificar en plazo con validación legal—.

Ya tienes las cuatro primitivas del módulo: cifrado simétrico, cifrado asimétrico, hash y MAC, y firma digital. En Protocolos Criptográficos (03-05) verás algo incómodo y necesario: que combinarlas todas correctamente no garantiza un sistema seguro, porque el ensamblaje tiene sus propios fallos. Es la lección donde por fin se explica en profundidad el TLS que llevas ocho lecciones dando por supuesto —su handshake paso a paso, sus versiones, sus suites, cómo verificarlo con openssl s_client y cómo configurarlo bien—, además de SSH, VPN, mTLS y los ataques clásicos contra protocolos: downgrade, stripping, MITM y repetición.

Curso de Fundamentos de Seguridad Informática

Módulo 1: Introducción a la Seguridad Informática

Módulo 2: Ciberseguridad

Módulo 3: Criptografía

Módulo 4: Gestión de Riesgos y Medidas de Protección

Módulo 5: Herramientas y Técnicas de Seguridad

Módulo 6: Buenas Prácticas y Normativas

Módulo 7: Proyecto Final

© Copyright 2026. Todos los derechos reservados