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 elGetHashCode de la lección sobre Dictionary: 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:
static string Sha256(string text) => Convert.ToHexStringLower( SHA256.HashData(Encoding.UTF8.GetBytes(text)));
// El código viejo de la tienda: SHA-256 sin sal.static Row Register(string email, string password) => new(email, "", Sha256(password));Cuatro compradores; ana y vera tienen la misma contraseña. La función SharedRows cuenta las filas cuyo hash aparece más de una vez:
static int SharedRows(List<Row> users) => users.GroupBy(row => row.Hash) .Where(group => group.Count() > 1) .Sum(group => group.Count());List<Row> users =[ Register("ana@example.com", "qwerty"), Register("boris@example.com", "sofia2024"), Register("vera@example.com", "qwerty"), Register("georgi@example.com", "Kp7#vR!q2Lm9"),];Console.WriteLine("La tabla users:");foreach (var row in users) Console.WriteLine($"{row.Email,-20}{row.Hash[..16]}…");Console.WriteLine($"Filas con un hash en común: {SharedRows(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.static Row Register(string email, string password){ string salt = Convert.ToHexStringLower( RandomNumberGenerator.GetBytes(16)); return new(email, salt, Sha256(salt + password));}RandomNumberGenerator 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:
List<Row> users =[ Register("ana@example.com", "qwerty"), Register("boris@example.com", "sofia2024"), Register("vera@example.com", "qwerty"), Register("georgi@example.com", "Kp7#vR!q2Lm9"),];Console.WriteLine($"Mismo registro para ana y vera: {users[0].Hash == users[2].Hash}");Console.WriteLine($"Filas con un hash en común: {SharedRows(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
// demo; en un servidor real 600_000 (OWASP)const int Iterations = 10_000;
static string HashPassword(string password){ byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] hash = Kdf(password, salt, Iterations); return $"pbkdf2_sha256${Iterations}$" + $"{Convert.ToBase64String(salt)}$" + Convert.ToBase64String(hash);}
static bool Verify(string password, string record){ string[] parts = record.Split('$'); int iterations = int.Parse(parts[1]); byte[] salt = Convert.FromBase64String(parts[2]); byte[] expected = Convert.FromBase64String(parts[3]); byte[] actual = Kdf(password, salt, iterations); return CryptographicOperations.FixedTimeEquals( expected, actual);}static string Sha256(string text) => Convert.ToHexStringLower( SHA256.HashData(Encoding.UTF8.GetBytes(text)));
// El código viejo de la tienda: SHA-256 sin sal.static Row Register(string email, string password) => new(email, "", Sha256(password));// Una sal aleatoria propia para cada fila.static Row Register(string email, string password){ string salt = Convert.ToHexStringLower( RandomNumberGenerator.GetBytes(16)); return new(email, salt, Sha256(salt + password));}// demo; en un servidor real 600_000 (OWASP)const int Iterations = 10_000;
static string HashPassword(string password){ byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] hash = Kdf(password, salt, Iterations); return $"pbkdf2_sha256${Iterations}$" + $"{Convert.ToBase64String(salt)}$" + Convert.ToBase64String(hash);}
static bool Verify(string password, string record){ string[] parts = record.Split('$'); int iterations = int.Parse(parts[1]); byte[] salt = Convert.FromBase64String(parts[2]); byte[] expected = Convert.FromBase64String(parts[3]); byte[] actual = Kdf(password, salt, iterations); return CryptographicOperations.FixedTimeEquals( 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: PBKDF2 repite HMAC-SHA256 10 000 veces aquí (una demo). En un servidor real son 600 000.
- 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 PBKDF2: HMAC-SHA256, repetido tantas iteraciones como indiques.static byte[] Kdf(string password, byte[] salt, int iterations) => Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, 32);Rfc2898DeriveBytes.Pbkdf2 es un método estático desde .NET 6: contraseña, sal, iteraciones, algoritmo y longitud en bytes (32).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.
// demo; en un servidor real 600_000 (OWASP)const int Iterations = 10_000;
static string HashPassword(string password){ byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] hash = Kdf(password, salt, Iterations); return $"pbkdf2_sha256${Iterations}$" + $"{Convert.ToBase64String(salt)}$" + Convert.ToBase64String(hash);}
static bool Verify(string password, string record){ string[] parts = record.Split('$'); int iterations = int.Parse(parts[1]); byte[] salt = Convert.FromBase64String(parts[2]); byte[] expected = Convert.FromBase64String(parts[3]); byte[] actual = Kdf(password, salt, iterations); return CryptographicOperations.FixedTimeEquals( expected, actual);}string record = HashPassword("qwerty");string[] parts = record.Split('$');Console.WriteLine($"Algoritmo y coste: {parts[0]}, {parts[1]}");Console.WriteLine($"Inicio de sesión con la contraseña correcta: {Verify("qwerty", record)}");Console.WriteLine($"Inicio de sesión con una contraseña incorrecta: {Verify("qwerty1", record)}");Console.WriteLine($"Una contraseña, dos registros, iguales: {record == HashPassword("qwerty")}");Algoritmo y coste: pbkdf2_sha256, 10000Inicio 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 600 000 iteraciones para PBKDF2-HMAC-SHA256. 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á?
string first = HashPassword("qwerty");string second = HashPassword("qwerty");Console.WriteLine(first == second);Console.WriteLine(Verify("qwerty", first));Console.WriteLine(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?
byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.(
expected, actual);CryptographicOperations.FixedTimeEquals 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 HashPassword.
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í HashPassword toma el coste como parámetro, y cada registro guarda el suyo, así los registros viejos siguen funcionando.
// el año pasado era 10_000const int Iterations = 20_000;
static int CostOf(string record) => int.Parse(record.Split('$')[1]);
static bool Login(Account account, string password){ if (!Verify(password, account.Record)) return false; if (CostOf(account.Record) < Iterations) { // solo ahora tenemos la contraseña en la mano: hash con el coste nuevo account.Record = HashPassword(password, Iterations); } return true;}var account = new Account(HashPassword("qwerty", 10_000));Console.WriteLine($"Coste antes del inicio de sesión: {CostOf(account.Record)}");Console.WriteLine($"Inicio de sesión: {Login(account, "qwerty")}");Console.WriteLine($"Coste después del inicio de sesión: {CostOf(account.Record)}");Coste antes del inicio de sesión: 10000Inicio de sesión: TrueCoste después del inicio de sesión: 20000Má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:
ASP.NET Core Identity tiene PasswordHasher<TUser>: PBKDF2 con la sal y el coste en el registro, y PasswordVerificationResult.SuccessRehashNeeded, que te dice exactamente cuándo hay que volver a hashear. Para argon2id y bcrypt hay paquetes de NuGet.
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 ChangePassword(account, oldPassword, newPassword), 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 y delegados · LINQ · record, switch 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