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 elhash() 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))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: 2Las 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))Mismo registro para ana y vera: FalseFilas con un hash en común: 0Las 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)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))# Una sal aleatoria propia para cada fila.def register(email, password): salt = secrets.token_hex(16) return Row(email, salt, sha256(salt + password))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
- La tabla users con el código viejo de la tienda: un correo y el SHA-256 de la contraseña, sin sal.
- 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.
- Ahora cada fila recibe una sal aleatoria propia, y la fila guarda SHA-256(sal + contraseña).
- 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.
- 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.
- El registro guarda todo lo necesario para comprobar después: algoritmo $ coste $ sal $ hash. La contraseña en sí no está en él.
- 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.
- 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"))Algoritmo y coste: scrypt, 4096Inicio de sesión con la contraseña correcta: TrueInicio de sesión con una contraseña incorrecta: FalseUna contraseña, dos registros, iguales: FalseEl 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))Cada registro tiene una sal aleatoria propia, así que los dos registros son distintos. La comprobación lee la sal del registro, por eso "qwerty" pasa en los dos.
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)hmac.compare_digest compara todos los bytes, esté donde esté la primera diferencia.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.
Una sola sal para todas las filas es casi como no tener sal: las contraseñas iguales siguen dando registros iguales, y un cálculo sigue valiendo para toda la tabla. La sal tiene que ser aleatoria y distinta para cada fila, creada en cada llamada a hash_password.
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 Trueaccount = 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))Coste antes del inicio de sesión: 2048Inicio de sesión: TrueCoste después del inicio de sesión: 4096Má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:
- comprueba la contraseña actual con
verifyy rechaza si es incorrecta; - rechaza una contraseña nueva de menos de 12 caracteres;
- rechaza una contraseña nueva que (sin distinguir mayúsculas y minúsculas) esté en la lista
password1234,qwerty123456,123456789012; - 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:
Contraseña actual incorrectaLa contraseña nueva tiene menos de 12 caracteresEsta contraseña está en las listas de contraseñas filtradasContraseña cambiadaInicio de sesión con la contraseña nueva: TrueMás tareas
- Un hash nuevo al iniciar sesión. Añade
loginde «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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Códigos hash y funciones hash · Lambdas · Listas por comprensión y generadores · dataclass, match y patrones
- Continúa en: Sesiones, cookies y CSRF · JWT y sus trampas · Criptografía que no escribes tú
- Ejercicios sobre el tema: Seguridad para programadores