Ir al contenido
wannacode

Contraseñas

En resumen: Las contraseñas se guardan como un hash lento con sal (bcrypt o argon2id), no cifradas ni con SHA-256 rápido, para que una filtración de la base de datos no entregue las contraseñas en sí.

Problema

La copia de seguridad que se hizo pública

La tienda guarda a sus compradores en una tabla users: un correo y una contraseña. Alguien sube una copia de seguridad de la base de datos a una carpeta pública, y una semana después está en internet. Lo que pierden los compradores depende solo de cómo se guardó la contraseña:

  • como texto: todas las contraseñas están a la vista. Mucha gente usa la misma contraseña en todas partes, así que también se filtran sus cuentas de correo;
  • cifrada con una clave que está junto a la base de datos: lo mismo. La clave está en la configuración del mismo servidor y se filtra con él;
  • como SHA-256 sin sal: las contraseñas no se ven, pero contraseñas iguales dan hashes iguales. La propia tabla muestra quién comparte contraseña con quién. Y quien tiene la tabla puede tomar una lista de contraseñas comunes, hashear cada una y buscar coincidencias: es un ataque de diccionario, y SHA-256 se hizo para ser rápido.

Una filtración nunca se puede descartar del todo. Por eso la contraseña se guarda de modo que una filtración no entregue las contraseñas.

La idea

Guarda la huella, no la contraseña

El servidor no necesita conocer la contraseña. Solo tiene que comprobar si la contraseña escrita es la misma. Para eso sirve un hash criptográfico: una función que convierte texto en una huella de longitud fija y no se puede revertir. La misma entrada da la misma huella, y la huella no lleva de vuelta a la entrada.

Este no es el hash() de la lección sobre dict y set: aquel es rápido, corto y colisiona a menudo, porque está hecho para cubetas. Un hash criptográfico es otra cosa.

El código viejo de la tienda guarda el SHA-256 de la contraseña:

class Row(NamedTuple):
email: str
salt: str
hash: str
def sha256(text):
return hashlib.sha256(text.encode()).hexdigest()
# El código viejo de la tienda: SHA-256 sin sal.
def register(email, password):
return Row(email, "", sha256(password))

Cuatro compradores; ana y vera tienen la misma contraseña. La función shared_rows cuenta las filas cuyo hash aparece más de una vez:

def shared_rows(users):
per_hash = Counter(row.hash for row in users)
return sum(1 for row in users if per_hash[row.hash] > 1)
users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
]
print("La tabla users:")
for row in users:
print(f"{row.email:<20}{row.hash[:16]}…")
print("Filas con un hash en común:", shared_rows(users))
Salida
La tabla users:
ana@example.com 65e84be33532fb78…
boris@example.com 6191711a1871a6bd…
vera@example.com 65e84be33532fb78…
georgi@example.com 9cbd9835924cf733…
Filas con un hash en común: 2

Las filas de ana y vera son iguales. Sin romper nada, cualquiera con la tabla ve que las dos tienen una sola contraseña. Si una la revela, revela también la de la otra.

La idea

Una sal: cada fila distinta

La solución para las filas iguales es una sal: bytes aleatorios, distintos para cada fila, que se ponen junto a la contraseña antes del hash. La sal no es un secreto. Está a la vista en la fila, porque el inicio de sesión la necesita otra vez.

# Una sal aleatoria propia para cada fila.
def register(email, password):
salt = secrets.token_hex(16)
return Row(email, salt, sha256(salt + password))

secrets es un generador criptográfico de números aleatorios: su siguiente valor no se puede predecir. (Por qué no sirve el generador normal se explica en la lección sobre criptografía.)

La sal es distinta en cada ejecución, así que el programa imprime solo lo que no cambia:

users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
]
print("Mismo registro para ana y vera:", users[0].hash == users[2].hash)
print("Filas con un hash en común:", shared_rows(users))
Salida
Mismo registro para ana y vera: False
Filas con un hash en común: 0

Las contraseñas iguales ya no se notan, y un cálculo vale solo para una fila. Pero SHA-256 sigue siendo rápido: una tarjeta gráfica calcula miles de millones por segundo, así que el ataque de diccionario sigue funcionando, solo que fila por fila. El hash además tiene que ser lento a propósito.

Míralo

La tabla users: sin sal, con sal, con hash lento

N = 2**12 # demo; en un servidor real 2**17 (OWASP)
def hash_password(password):
salt = secrets.token_bytes(16)
digest = kdf(password, salt, N)
return f"scrypt${N}${b64(salt)}${b64(digest)}"
def verify(password, record):
_, n, salt, expected = record.split("$")
actual = kdf(password, b64decode(salt), int(n))
return hmac.compare_digest(b64decode(expected), actual)
Paso a paso, en palabras
  1. La tabla users con el código viejo de la tienda: un correo y el SHA-256 de la contraseña, sin sal.
  2. Contraseñas iguales dan hashes iguales. ana y vera tienen el mismo valor, así que la propia tabla muestra quién comparte contraseña: 2 filas.
  3. Ahora cada fila recibe una sal aleatoria propia, y la fila guarda SHA-256(sal + contraseña).
  4. Cada fila es distinta, también ana y vera: 0 filas con un hash en común. Pero SHA-256 es rápido, así que cada intento sobre una fila sigue siendo barato.
  5. La solución es un hash lento: scrypt con coste N = 4096 aquí (una demo), que además necesita memoria. En un servidor real N = 2^17.
  6. El registro guarda todo lo necesario para comprobar después: algoritmo $ coste $ sal $ hash. La contraseña en sí no está en él.
  7. Al iniciar sesión el código lee el coste y la sal del registro y hashea la contraseña escrita de la misma manera.
  8. Los dos hashes se comparan en tiempo constante: "qwerty" da ✓, "qwerty1" da ✗. Las contraseñas iguales ya no se notan, y cada intento es lento.

El último cuadro junta los tres pasos: la columna guarda un registro con sal y coste, no hay dos filas iguales, y el inicio de sesión comprueba la contraseña contra el registro.

La idea

Un hash lento y el registro

Un hash lento de contraseñas repite su trabajo muchas veces. Cuántas veces es el coste (work factor). Un inicio de sesión lo calcula una vez y espera una fracción de segundo. Quien tiene la tabla filtrada paga el mismo precio por cada intento.

La biblioteca estándar tiene scrypt: además de tiempo necesita memoria, y la memoria es cara para las tarjetas gráficas.
def b64(data):
return b64encode(data).decode()
def kdf(password, salt, n):
return hashlib.scrypt(password.encode(), salt=salt,
n=n, r=8, p=1, dklen=32)
hashlib.scrypt toma n (el coste, una potencia de dos), r (el tamaño de bloque), p (paralelismo) y dklen (la longitud en bytes).

El registro guarda todo lo que necesita la comprobación: algoritmo, coste, sal y hash, con $ entre ellos. Así el coste puede subir más tarde sin romper los registros viejos.

N = 2**12 # demo; en un servidor real 2**17 (OWASP)
def hash_password(password):
salt = secrets.token_bytes(16)
digest = kdf(password, salt, N)
return f"scrypt${N}${b64(salt)}${b64(digest)}"
def verify(password, record):
_, n, salt, expected = record.split("$")
actual = kdf(password, b64decode(salt), int(n))
return hmac.compare_digest(b64decode(expected), actual)
record = hash_password("qwerty")
parts = record.split("$")
print(f"Algoritmo y coste: {parts[0]}, {parts[1]}")
print("Inicio de sesión con la contraseña correcta:", verify("qwerty", record))
print("Inicio de sesión con una contraseña incorrecta:", verify("qwerty1", record))
print("Una contraseña, dos registros, iguales:", record == hash_password("qwerty"))
Salida
Algoritmo y coste: scrypt, 4096
Inicio de sesión con la contraseña correcta: True
Inicio de sesión con una contraseña incorrecta: False
Una contraseña, dos registros, iguales: False

El coste del programa es pequeño, para que corra rápido. En un servidor real OWASP recomienda N = 2¹⁷ (r = 8, p = 1) para scrypt. En un proyecto real normalmente no lo escribes tú, sino que usas bcrypt o argon2id de una biblioteca probada (los detalles, en «Más precisamente, para curiosos»).

Pruébalo

Predice la salida

Una misma contraseña, hasheada dos veces. ¿Qué imprimirá?

first = hash_password("qwerty")
second = hash_password("qwerty")
print(first == second)
print(verify("qwerty", first))
print(verify("qwerty", second))
Respuesta

Pruébalo

Completa

Al iniciar sesión se comparan los dos hashes. Una comparación normal se detiene en el primer byte distinto, y el tiempo de respuesta puede revelar hasta dónde coinciden. ¿Qué comparación recorre siempre todos los bytes?

actual = kdf(password, b64decode(salt), int(n))
return hmac.(b64decode(expected), actual)

Pruébalo

Encuentra el error

Un compañero «simplifica» el código: en lugar de una sal aleatoria para cada fila, una sola sal para todas. El hash lento es el mismo. ¿Dónde está el problema?

Elige la línea con el error.

Míralo

El coste crece con los años

Las computadoras se vuelven más rápidas, así que un coste que bastaba el año pasado no basta pasado un tiempo. Los registros viejos no se pueden recalcular: un hash no va hacia atrás, y el servidor tiene la contraseña solo en el momento de un inicio de sesión. Por eso el hash nuevo se hace en un inicio de sesión correcto. Aquí hash_password toma el coste como parámetro, y cada registro guarda el suyo, así los registros viejos siguen funcionando.

N = 2**12 # el año pasado era 2**11
def cost_of(record):
return int(record.split("$")[1])
def login(account, password):
if not verify(password, account.record):
return False
if cost_of(account.record) < N:
# solo ahora tenemos la contraseña en la mano: hash con el coste nuevo
account.record = hash_password(password, N)
return True
account = Account(hash_password("qwerty", 2**11))
print("Coste antes del inicio de sesión:", cost_of(account.record))
print("Inicio de sesión:", login(account, "qwerty"))
print("Coste después del inicio de sesión:", cost_of(account.record))
Salida
Coste antes del inicio de sesión: 2048
Inicio de sesión: True
Coste después del inicio de sesión: 4096
Más precisamente, para curiosos

Qué parámetros (OWASP, Password Storage Cheat Sheet):

  • argon2id: 19 MiB de memoria, 2 iteraciones, paralelismo 1. La primera opción para un proyecto nuevo;
  • scrypt: N = 2¹⁷, r = 8, p = 1. Necesita 128 MiB de memoria, y tanto en Python como en Node el límite por defecto es de 32 MiB, así que se sube con maxmem;
  • bcrypt: coste 10 o más. Usa solo los primeros 72 bytes de la contraseña;
  • PBKDF2-HMAC-SHA256: 600 000 iteraciones, cuando se exige un algoritmo aprobado por FIPS.

Qué usan los proyectos reales. Rara vez lo escribes tú; usas la biblioteca del framework: Los paquetes argon2-cffi y bcrypt (la biblioteca estándar no tiene ninguno). Django guarda las contraseñas exactamente con la forma de esta lección: pbkdf2_sha256$<iterations>$<salt>$<hash>, y vuelve a hashear al iniciar sesión cuando sube el coste.

Pepper. Algunos añaden además un valor secreto, común a todas las filas, que vive fuera de la base de datos (en un almacén de secretos, mira la lección sobre secretos). Una filtración solo de la base de datos entonces no basta. Ayuda, pero no sustituye a la sal ni al hash lento.

La política de contraseñas. Una contraseña larga (al menos 12 caracteres) y una comprobación contra listas de contraseñas filtradas ayudan más que reglas como «una mayúscula y un dígito». Esa es también la tarea de abajo.

Cómo es en los otros lenguajes: Java y C# tienen PBKDF2 en la biblioteca estándar pero no scrypt; Python y Node tienen los dos (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). Ninguno de los cuatro trae argon2id ni bcrypt incorporados.

Ejercicio

Cambiar la contraseña

Resuélvelo en tu editor.

La cuenta del comprador tiene la contraseña "qwerty". Escribe change_password(account, old_password, new_password), que:

  1. comprueba la contraseña actual con verify y rechaza si es incorrecta;
  2. rechaza una contraseña nueva de menos de 12 caracteres;
  3. rechaza una contraseña nueva que (sin distinguir mayúsculas y minúsculas) esté en la lista password1234, qwerty123456, 123456789012;
  4. si no, guarda un registro nuevo con una sal nueva y devuelve «Contraseña cambiada».

Llámala con ("qwerty1", "new-pass-2026!"), ("qwerty", "short1"), ("qwerty", "Qwerty123456") y ("qwerty", "tram-9-to-vitosha"), y después comprueba el inicio de sesión con la contraseña nueva:

Salida
Contraseña actual incorrecta
La contraseña nueva tiene menos de 12 caracteres
Esta contraseña está en las listas de contraseñas filtradas
Contraseña cambiada
Inicio de sesión con la contraseña nueva: True

Más tareas

  • Un hash nuevo al iniciar sesión. Añade login de «El coste crece con los años» y comprueba que una cuenta vieja con un coste menor sale del inicio de sesión con el nuevo.
  • El mismo tiempo para un correo incorrecto. Si el correo no existe, el inicio de sesión responde de inmediato; si existe, calcula el hash lento. ¿Cómo harías que los dos casos tarden lo mismo?

Test

Ponte a prueba

1. ¿Por qué las contraseñas no se guardan cifradas con una clave?
2. ¿Qué hace una sal, y qué NO hace?
3. ¿Por qué un hash lento ayuda en lugar de perjudicar?
4. ¿Por qué se compara el hash en tiempo constante al iniciar sesión?
5. Subes el coste de 10 000 a 20 000 iteraciones. ¿Qué pasa con los registros viejos?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·