En la lección anterior cerramos la deuda del curso: los historiales de MediNube ya se cifran en disco con AES-256-GCM en el formato v1. Pero cada ejemplo empezó con una trampa que prometimos resolver — clave = os.urandom(32) —, un placeholder que esquiva la pregunta: en un sistema real, ¿de dónde sale esa clave de 32 bytes? El administrador de una clínica no memoriza 32 bytes aleatorios; escribe una contraseña. Y una contraseña no es una clave. Esta lección cubre el puente entre ambos mundos: las funciones de derivación de claves (KDF). Aprenderás a convertir contraseñas en claves con KDFs lentas a propósito (PBKDF2, scrypt, Argon2), el papel del salt que prometimos en 01-03, y a derivar múltiples subclaves de una clave maestra fuerte con HKDF. Como última lección del módulo, cerraremos el módulo entero y abriremos el siguiente.

Contenido

  1. Por qué una contraseña no es una clave
  2. Qué es una KDF y qué propiedades tiene
  3. KDFs para contraseñas: PBKDF2, scrypt y Argon2
  4. El papel del salt
  5. HKDF: derivar subclaves de una clave maestra fuerte
  6. Caso práctico: backup exportable de MediNube con scrypt
  7. Derivar claves para cifrar vs guardar contraseñas para autenticar

Por qué una contraseña no es una clave

Una clave AES-256 son 32 bytes de entropía pura (256 bits reales), salidos del CSPRNG (01-03). Una contraseña humana es algo muy distinto:

Clave criptográfica Contraseña humana
Longitud Fija (32 bytes) Variable (6, 12, 40 caracteres...)
Composición Bytes cualesquiera (0–255) Caracteres imprimibles, patrones, palabras
Entropía Máxima: 256 bits reales Baja: "Verano2026!" tiene quizá 30-40 bits
Origen CSPRNG La memoria de una persona

Dos problemas te impiden usar una contraseña directamente como clave:

  1. Formato incompatible. AES-256 exige exactamente 32 bytes. Una contraseña de 11 caracteres son 11 bytes; una de 40, cuarenta. No encaja.
  2. Entropía insuficiente y estructurada. Aunque la recortaras o rellenaras a 32 bytes, seguiría teniendo la baja entropía de origen: un atacante no probaría 2^256 claves, probaría el diccionario de contraseñas comunes — millones, no cuatrillones. La fortaleza teórica de AES-256 se desploma a la de la contraseña.

Necesitamos una función que tome esa contraseña débil y de longitud variable y produzca una clave de 32 bytes con el formato correcto y que, además, haga el ataque por diccionario lo más caro posible. Eso es una KDF.

Qué es una KDF y qué propiedades tiene

Una KDF (Key Derivation Function, función de derivación de claves) transforma un material de entrada (una contraseña, o una clave maestra) en una o más claves criptográficas con el tamaño y las propiedades deseadas. Propiedades que definen una buena KDF:

  • Salida de longitud fija y uniforme. Produce exactamente los bytes que pidas (32 para AES-256), y esos bytes son indistinguibles de aleatorios aunque la entrada tenga estructura.
  • Determinista. La misma entrada (misma contraseña + mismo salt + mismos parámetros) produce siempre la misma clave. Imprescindible: si no, no podrías descifrar mañana lo que ciframos hoy.
  • Unidireccional. De la clave de salida no se puede volver a la contraseña.
  • Costosa a voluntad (para las KDF de contraseñas): parametrizable para tardar deliberadamente, encareciendo la fuerza bruta.

Aquí hay una división fundamental que estructura el resto de la lección:

  • KDFs lentas para entradas de baja entropía (contraseñas): PBKDF2, scrypt, Argon2. Su gracia es ser caras — cuanto más cuesta cada intento, menos intentos por segundo puede hacer un atacante.
  • KDFs rápidas para entradas ya fuertes (una clave maestra de 256 bits): HKDF. Aquí no hace falta ralentizar nada, porque la entrada ya tiene toda la entropía; solo queremos repartirla en varias subclaves.

Usar la herramienta equivocada es un error clásico: HKDF sobre una contraseña no la protege (es rápida, la fuerza bruta vuela); una KDF lenta para trocear una clave maestra solo desperdicia CPU.

KDFs para contraseñas: PBKDF2, scrypt y Argon2

Las tres derivan una clave de una contraseña, y las tres son lentas a propósito. Se diferencian en cómo imponen el coste, lo que determina su resistencia a distintos atacantes (que hoy usan GPUs y hardware dedicado, no CPUs).

  • PBKDF2: la clásica (y la que exige FIPS). Su coste es el número de iteraciones: repite un HMAC decenas o cientos de miles de veces. Problema: solo cuesta cómputo, y las GPUs hacen cómputo en masa muy barato. Sigue siendo aceptable con muchas iteraciones, pero es la más débil de las tres frente a hardware moderno.
  • scrypt: añade coste de memoria (memory-hard). Obliga a usar una gran cantidad de RAM además de CPU, y la memoria es cara de paralelizar en GPUs/ASICs. Mucho mejor que PBKDF2 contra atacantes con hardware especializado. Viene incluida en pyca/cryptography.
  • Argon2: el estado del arte, ganador de la Password Hashing Competition (2015). Memory-hard y con parámetros independientes de tiempo, memoria y paralelismo. La variante recomendada es Argon2id. pyca/cryptography no la trae; se usa el paquete aparte argon2-cffi (pip install argon2-cffi).
KDF Coste que impone Resistencia a GPU/ASIC En pyca/cryptography Cuándo elegirla
PBKDF2 Iteraciones (solo CPU) Baja-media Si necesitas FIPS/compatibilidad
scrypt CPU + memoria Buena Buena opción general con lo que trae la librería
Argon2id CPU + memoria + paralelismo ajustables La mejor No (usa argon2-cffi) Recomendada para proyectos nuevos

Parámetros recomendados orientativos (2026; verifícalos siempre con las guías OWASP vigentes, porque suben con el hardware):

  • PBKDF2-HMAC-SHA256: ≥ 600.000 iteraciones.
  • scrypt: n = 2^17 (131072), r = 8, p = 1 (≈128 MB de memoria).
  • Argon2id: memoria ≥ 19 MiB, ≥ 2 iteraciones, paralelismo 1 (mínimos OWASP; sube la memoria si el servidor lo permite).

La regla para calibrar: ajusta los parámetros para que derivar UNA clave tarde entre 0,1 y 0,5 s en tu hardware. Es un pestañeo para tu usuario legítimo (deriva una vez), pero multiplica por millones el coste del atacante que prueba diccionarios. Ejemplo con scrypt, la que usaremos en MediNube:

import os
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt

contraseña = "C0ntraseña-admin-Sol!".encode("utf-8")   # baja entropía, longitud variable
salt = os.urandom(16)                                    # 16 bytes del CSPRNG, ÚNICO (ver abajo)

def deriva_clave(contraseña: bytes, salt: bytes) -> bytes:
    kdf = Scrypt(
        salt=salt,
        length=32,        # queremos 32 bytes -> clave AES-256
        n=2**17,          # coste de CPU/memoria (potencia de 2)
        r=8,
        p=1,
    )
    return kdf.derive(contraseña)

clave = deriva_clave(contraseña, salt)
print(len(clave), clave.hex())    # 32 bytes de aspecto aleatorio

# Verificar una contraseña candidata: se RE-deriva con el MISMO salt y se compara.
kdf_v = Scrypt(salt=salt, length=32, n=2**17, r=8, p=1)
kdf_v.verify(contraseña, clave)   # no lanza -> correcta; InvalidKey si no coincide

Detalles:

  • Scrypt(...).derive(contraseña) produce los 32 bytes. Un objeto Scrypt es de un solo uso: para verificar o volver a derivar, crea uno nuevo.
  • El salt de 16 bytes debe guardarse (lo vemos ahora): sin él no se puede re-derivar la misma clave.
  • kdf.verify(contraseña, clave_esperada) re-deriva y compara en tiempo constante; lanza InvalidKey si no cuadra.

El papel del salt

El salt es un valor aleatorio y único que se combina con la contraseña antes de derivar. En 01-03 prometimos explicar los salts; aquí está su razón de ser. Sin salt, deriva(contraseña) sería una función fija, y eso abre dos ataques:

  1. Tablas precalculadas (rainbow tables). Un atacante precalcula la derivación de millones de contraseñas comunes una vez y luego busca coincidencias instantáneamente. El salt lo mata: con un salt distinto por usuario, la tabla tendría que rehacerse para cada salt — inviable.
  2. Ataques en lote. Sin salt, dos administradores con la misma contraseña producen la misma clave, y romper una rompe las dos. Un atacante ataca a todos a la vez. Con salt único, cada derivación es un problema independiente: hay que atacar de uno en uno.

Propiedades del salt, que sorprenden a quien empieza:

  • Es único por derivación (os.urandom(16) cada vez).
  • Es aleatorio, del CSPRNG.
  • NO es secreto. Se guarda en claro, junto al resultado. Su trabajo no es ocultarse, sino ser distinto en cada caso para individualizar cada derivación. Es exactamente la misma filosofía que el IV/nonce de 02-02: público pero irrepetible.

Por eso, en disco, un registro protegido con contraseña siempre guarda el salt al lado:

guardado = salt (16 bytes, en claro)  ||  datos derivados/cifrados

HKDF: derivar subclaves de una clave maestra fuerte

Cambiamos de escenario. A veces la entrada ya es fuerte: una clave maestra de 256 bytes de entropía real (la de MediNube). No queremos "protegerla" con lentitud — ya es inatacable por fuerza bruta —; queremos derivar de ella varias subclaves independientes, una por propósito. ¿Por qué no usar la misma clave maestra para todo? Por separación de dominios: si cada uso tiene su subclave, comprometer o reutilizar una no afecta a las demás, y evitas colisiones de nonce entre subsistemas distintos.

La herramienta es HKDF (HMAC-based KDF), rápida porque la entrada ya tiene entropía. Trabaja en dos fases conceptuales: extract (condensa la entrada en una clave interna uniforme) y expand (la estira en tantos bytes como pidas). Su parámetro estrella es info: una etiqueta de contexto que separa las derivaciones. Misma clave maestra + info distinto = subclaves criptográficamente independientes.

import os
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

clave_maestra = os.urandom(32)    # entrada YA fuerte (256 bits reales)

def subclave(maestra: bytes, contexto: bytes) -> bytes:
    hkdf = HKDF(
        algorithm=hashes.SHA256(),
        length=32,                 # 32 bytes -> subclave AES-256
        salt=None,                 # opcional aquí; la entrada ya es de máxima entropía
        info=contexto,             # ETIQUETA que separa esta subclave de las demás
    )
    return hkdf.derive(maestra)

k_historiales = subclave(clave_maestra, b"medinube:historiales:v1")
k_backups     = subclave(clave_maestra, b"medinube:backups:v1")

print(k_historiales != k_backups)   # True: dos subclaves independientes de UNA maestra

Con esto, la clave maestra de MediNube da lugar a k_historiales (la que alimenta el cifrar_historial_v1 de 02-03) y a k_backups (la de los respaldos), sin que ninguna revele nada de la otra. Nota la diferencia con las KDFs de contraseña: aquí info reemplaza al protagonismo del salt, no hay parámetro de coste (es rápida a propósito), y la entrada debe ser fuerte de partida — HKDF no rescata una contraseña débil.

Caso práctico: backup exportable de MediNube con scrypt

Juntemos las piezas del módulo en un caso real. MediNube permite a un administrador de clínica exportar un backup cifrado de sus historiales, protegido por una passphrase que solo él conoce (para poder restaurarlo en otro entorno sin depender de la clave maestra del servidor). El flujo une scrypt (contraseña → clave) con AES-256-GCM (02-03):

import os
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

SCRYPT_N, SCRYPT_R, SCRYPT_P = 2**17, 8, 1

def exportar_backup(passphrase: str, datos: bytes) -> bytes:
    """salt(16) || nonce(12) || AES-256-GCM(cifrado+tag). La passphrase NO se guarda."""
    salt = os.urandom(16)
    clave = Scrypt(salt=salt, length=32, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P).derive(
        passphrase.encode("utf-8"))
    nonce = os.urandom(12)
    aad = b"medinube-backup;formato=v1"
    cifrado = AESGCM(clave).encrypt(nonce, datos, aad)
    return salt + nonce + cifrado

def restaurar_backup(passphrase: str, blob: bytes) -> bytes:
    """Re-deriva la clave con el salt guardado y descifra. Falla si la passphrase es mala."""
    salt, nonce, cifrado = blob[:16], blob[16:28], blob[28:]
    clave = Scrypt(salt=salt, length=32, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P).derive(
        passphrase.encode("utf-8"))
    aad = b"medinube-backup;formato=v1"
    return AESGCM(clave).decrypt(nonce, cifrado, aad)   # InvalidTag si passphrase incorrecta

# Uso:
datos = "Backup Clinica Sol: historial de Ana Perez, ...".encode("utf-8")
blob = exportar_backup("Frase-larga-y-secreta-del-admin-2026", datos)

print(restaurar_backup("Frase-larga-y-secreta-del-admin-2026", blob).decode("utf-8"))  # OK

try:
    restaurar_backup("passphrase-equivocada", blob)
except Exception as e:
    print("Rechazado:", type(e).__name__)    # InvalidTag: la clave derivada no cuadra

Observa cómo encaja todo el módulo:

  • El salt (16 bytes, público) se guarda al principio del blob: sin él no se puede re-derivar la clave. El nonce (12 bytes, público, único) también viaja incluido — como en el formato v1 de 02-03.
  • La passphrase nunca se almacena: solo existe en la cabeza del administrador. Si se equivoca al restaurar, la clave derivada es otra y el decrypt de GCM falla con InvalidTag — la integridad de AEAD detecta la passphrase incorrecta "gratis".
  • scrypt hace que un atacante que robe el blob y quiera probar passphrases por diccionario pague el coste de scrypt en cada intento.

Datos ficticios, como siempre; un despliegue real de backups sanitarios exige revisión de seguridad y compliance (RGPD).

Derivar claves para cifrar vs guardar contraseñas para autenticar

Una distinción crucial que evita uno de los líos más comunes. En esta lección hemos usado KDFs con contraseñas para derivar una clave y cifrar datos (backups, historiales). Existe un uso parecido pero distinto de las mismas familias de funciones: almacenar contraseñas de usuario para autenticarlos cuando inician sesión (el clásico "iniciar sesión de ana.perez en el portal").

Derivar clave para cifrar (esta lección) Guardar contraseña para autenticar (03-03)
Objetivo Obtener una clave para AES Verificar que quien entra sabe la contraseña
Qué se guarda El dato cifrado (+ salt, nonce) El hash de la contraseña (+ salt, parámetros)
La clave derivada... se usa para cifrar/descifrar no se usa para nada; solo se compara
Funciones scrypt/Argon2/PBKDF2 (+ HKDF para subclaves) scrypt/Argon2/PBKDF2, en modo "hash de contraseña"

Comparten la maquinaria (KDFs lentas, salt, coste), pero el propósito difiere: aquí la clave derivada es un medio para cifrar; en autenticación, la salida es el fin — se guarda para recomprobarla en cada login, y jamás se cifra nada con ella. Ese segundo escenario, el almacenamiento seguro de contraseñas de usuario, se desarrolla por completo en la lección 03-03. Aquí solo lo anunciamos para que no confundas los dos usos.

Errores Comunes y Consejos

  • Usar una contraseña directamente como clave AES. El error que abre la lección. Siempre pasa por una KDF de contraseña.
  • Usar una KDF rápida (HKDF, o un hash simple) sobre una contraseña. No la protege: la fuerza bruta vuela. Para contraseñas, KDF lenta (scrypt/Argon2/PBKDF2). HKDF es solo para entradas ya fuertes.
  • Reutilizar el salt, o creerlo secreto. El salt es único por derivación y público; se guarda en claro junto al resultado. Reutilizarlo reabre las rainbow tables y los ataques en lote.
  • Copiar parámetros de coste de un tutorial viejo. Suben con el hardware: 1.000 iteraciones de PBKDF2 fue seguro en 2010 y hoy es un chiste. Calibra a 0,1–0,5 s y consulta OWASP.
  • Olvidar guardar salt/nonce/parámetros. Sin ellos no se puede re-derivar ni descifrar. Guárdalos junto al dato (lo hace el blob del backup).
  • Confundir derivar-para-cifrar con guardar-para-autenticar. Distinta finalidad; la autenticación de contraseñas es 03-03.

Ejercicios

  1. Sube el coste. Deriva una clave con scrypt usando n=2**14 y cronométralo con time.perf_counter(); repite con n=2**17. ¿Cuánto se multiplica el tiempo? Explica por qué ese encarecimiento perjudica muchísimo más al atacante que al usuario legítimo.

  2. La magia del info en HKDF. Deriva dos subclaves de la MISMA clave_maestra con info=b"historiales" e info=b"backups". Comprueba que son distintas. Luego deriva otra vez con info=b"historiales" y comprueba que coincide con la primera. ¿Qué propiedad de las KDF garantiza esa reproducibilidad?

  3. Passphrase incorrecta detectada gratis. Con exportar_backup/restaurar_backup, exporta un backup y restáuralo con una passphrase equivocada. ¿Por qué falla con InvalidTag y no con un error de "clave incorrecta" explícito? ¿Qué dos mecanismos del módulo (scrypt + AES-GCM) colaboran para producir ese fallo?

Soluciones

  1. Pasar de n=2**14 a n=2**17 es multiplicar el coste por 8, así que el tiempo se multiplica aproximadamente por 8 (p. ej. de ~15 ms a ~120 ms). Para el usuario legítimo es un pestañeo: deriva una sola vez al exportar o restaurar. Para el atacante que prueba un diccionario de, digamos, mil millones de passphrases, ese factor 8 multiplica por 8 el coste de cada uno de esos mil millones de intentos — de días a semanas. La asimetría (uno derivas, el atacante deriva millones) es justo lo que hace útil la lentitud.

  2. Las dos subclaves con info distinto salen diferentes; volver a derivar con info=b"historiales" reproduce exactamente la primera subclave. Lo garantiza la propiedad determinista de las KDF: misma entrada (clave maestra + info + parámetros) → misma salida. Sin ella no podríamos volver a obtener la subclave para descifrar mañana lo cifrado hoy. Y con info distinto, las subclaves quedan criptográficamente separadas pese a venir de la misma maestra.

  3. Falla con InvalidTag porque una passphrase equivocada, pasada por scrypt con el salt guardado, produce una clave derivada distinta de la original. Al intentar descifrar con esa clave errónea, el tag de AES-GCM (02-03) no valida y decrypt lanza InvalidTag. Colaboran los dos mecanismos: scrypt convierte "passphrase mala" en "clave mala" de forma determinista, y AES-GCM convierte "clave mala" en "fallo detectado" gracias a su verificación de integridad. No necesitamos comprobar la passphrase por separado: la propia AEAD lo hace, y en tiempo constante.

Conclusión

Con la derivación de claves cerramos el Módulo 2: Criptografía Simétrica y, con él, el arco que arrancó en el módulo 1. El recorrido ha sido completo: conocimos a AES y ChaCha20 (02-01), aprendimos que un cifrado en bloque necesita un modo y por qué ECB filtra mientras CBC y CTR exigen un IV/nonce irrepetible (02-02), dimos el salto al cifrado autenticado que une confidencialidad e integridad y saldamos por fin la deuda del curso cifrando los historiales de MediNube con AES-256-GCM en el formato v1 (02-03), y en esta lección hemos resuelto la última incógnita — de dónde salen las claves: convertir contraseñas en claves con KDFs lentas (PBKDF2, scrypt, Argon2) protegidas por un salt único y público, y repartir una clave maestra fuerte en subclaves independientes con HKDF. El balance de MediNube ha cambiado por completo desde el final del módulo 1: los historiales de Ana Pérez ya no están sin cifrar en disco, sino protegidos con cifrado autenticado; existe un módulo centralizado y criptoágil medinube.crypto; y sabemos derivar tanto la clave maestra desde una passphrase de administrador como las subclaves por propósito. La base simétrica está construida. Lo que aún nos falta son las piezas que garantizan la integridad y la identidad sin cifrar nada: ¿cómo se comprueba que un fichero no ha cambiado sin descifrarlo? ¿Cómo se autentica un mensaje entre dos partes que comparten una clave? Y la pregunta que hemos rozado dos veces — ¿cómo se guardan de forma segura las contraseñas de los usuarios que inician sesión en el portal de MediNube, un problema distinto al de derivar claves para cifrar? Todo eso es el Módulo 3: Hashes, MAC y Contraseñas, que empieza en la lección 03-01 con las funciones hash criptográficas — la primitiva sobre la que se construyen HMAC (03-02) y el almacenamiento seguro de contraseñas (03-03). Nos vemos en el módulo 3.

© Copyright 2026. Todos los derechos reservados