tutoriales·13 min de lectura

SHA-256 con Salt e Iteraciones: Implementa PBKDF2 desde cero

Aprende a implementar SHA-256 con salt aleatorio e iteraciones usando la Web Crypto API, exactamente como funciona nuestra herramienta de hash seguro.

DevToolsHub Team
·
SHA-256 con Salt e Iteraciones: Implementa PBKDF2 desde cero

El problema real: cuando el hash "seguro" no lo es

Imagina que acabas de lanzar tu primera aplicación con sistema de usuarios. Implementaste login con email y contraseña, y como has oído que MD5 está obsoleto, decidiste usar SHA-256. "Es del gobierno de EE.UU.", piensas, "tiene que ser seguro".

Tres meses después, alguien publica tu base de datos en un foro de hacking. Las contraseñas están "hasheadas" con SHA-256. Te sientes aliviado: "Al menos no están en texto plano".

Pero aquí viene la mala noticia: un atacante con una GPU moderna de consumo (una RTX 4090 cuesta unos 2000€) puede calcular mil millones de hashes SHA-256 por segundo. Eso significa que puede probar mil millones de contraseñas cada segundo. Tu "seguro" SHA-256 cae en minutos frente a un ataque de diccionario con las contraseñas más comunes.

Esto no es teoría. Le ha pasado a LinkedIn en 2012 (6 millones de hashes SHA-1 filtrados). A Adobe en 2013 (130 millones de contraseñas con cifrado reversible). La lista es larga y dolorosa.

La solución no es usar un algoritmo "más complejo". La solución es usar el algoritmo correcto de la forma correcta: PBKDF2 con salt e iteraciones.


¿Por qué SHA-256 simple no funciona para contraseñas?

SHA-256 es una función hash criptográfica diseñada para una cosa: verificar la integridad de datos de forma rápida. Es tan rápida que un atacante puede probar billones de combinaciones.

Piénsalo así: SHA-256 es como una cerradura de combinación que puedes girar muy rápido. Un ladrón experimentado puede probar todas las combinaciones posibles en horas. Lo que necesitas es una cerradura que se abra más lentamente cada vez que alguien prueba una combinación incorrecta.

Eso es exactamente lo que hace PBKDF2 (Password-Based Key Derivation Function 2):

  1. Añade salt: una cadena aleatoria única para cada usuario. Dos usuarios con la misma contraseña tendrán hashes completamente diferentes.
  2. Añade iteraciones: el algoritmo se ejecuta miles de veces deliberadamente. Con 100.000 iteraciones, calcular un hash tarda ~100ms en tu servidor moderno.

¿100ms? Para tu usuario es imperceptible. Pero para un atacante que quiere probar millones de contraseñas, multiplicas el tiempo por 100.000. Lo que antes tomaba minutos, ahora toma años.

Nuestra herramienta SHA-256 con PBKDF2 te permite experimentar con estos parámetros y ver en tiempo real cómo cambian los resultados.


Entendiendo el salt: tu escudo contra las tablas rainbow

Cuando era joven desarrollador, pensaba que el salt era como un "condimento" opcional. No entendía por qué era tan importante hasta que aprendí sobre las tablas rainbow.

Imagina que tienes una base de datos con 10.000 usuarios. Sin salt, si dos usuarios eligieron "password123" (sí, la gente sigue usando esa), ambos tendrán el mismo hash SHA-256:

ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f

Un atacante puede crear una tabla con los hashes de las 10 millones de contraseñas más comunes. Luego simplemente busca tu hash en esa tabla. Encuentra la coincidencia en milisegundos.

Con salt, cada usuario tiene un valor aleatorio único añadido a su contraseña antes de hashear. Si el salt de Ana es "a3f7c2e1..." y el de Luis es "b8d4e9f2...", aunque ambos usen "password123", sus hashes serán completamente diferentes:

Ana:  SHA256("password123" + "a3f7c2e1...") → 8f4e...
Luis: SHA256("password123" + "b8d4e9f2...") → 2b9a...

Las tablas rainbow son inútiles porque cada hash es único. El atacante tendría que recalcular la tabla para cada salt individual, lo que elimina completamente la ventaja de precalcular.

El salt no es secreto. De hecho, lo guardas junto al hash en la base de datos. Lo importante es que sea:

  • Único por usuario: nunca reutilizar el mismo salt
  • Aleatorio: generado con un CSPRNG (Generador de Números Pseudoaleatorios Criptográficamente Seguro)
  • Lo suficientemente largo: 16 bytes (128 bits) es el estándar

Las iteraciones: ralentizar al atacante sin molestar al usuario

Las iteraciones son el segundo ingrediente mágico. La idea es simple pero brillante: en lugar de hashear la contraseña una vez, la hasheas 100.000 veces (o más).

El proceso funciona así:

  1. Toma la contraseña + salt
  2. Calcula SHA-256 del resultado
  3. Toma ese resultado y calcula SHA-256 de nuevo
  4. Repite 99.998 veces más
  5. El resultado final es tu hash

Para tu servidor moderno, esto es una operación trivial que completa en ~100ms. El usuario no nota la diferencia entre 1ms y 100ms cuando hace login.

Pero para un atacante con GPUs, esto es devastador. Si antes podía probar 1.000.000.000 contraseñas/segundo, ahora solo puede probar 10.000/segundo. Eso es una reducción de 99.999% en velocidad.

¿Cuántas iteraciones usar?

OWASP (Open Web Application Security Project) actualiza sus recomendaciones periódicamente basándose en la capacidad computacional actual:

  • 2024: Mínimo 600.000 iteraciones para PBKDF2-SHA256
  • Nuestra herramienta usa 100.000 por defecto para que las demos no se hagan lentas, pero en producción deberías usar al menos 600.000

La tendencia es clara: cada año necesitas más iteraciones porque las GPUs son más rápidas. PBKDF2 te permite adaptarte simplemente cambiando un número.


Implementación paso a paso: desde la teoría al código

Ahora vamos a implementar esto en código real. Usaremos la Web Crypto API, que está disponible de forma nativa en todos los navegadores modernos y Node.js 18+.

Paso 1: Generar un salt aleatorio (el momento más crítico)

Aquí está el error que he visto en código de producción más veces de las que quiero recordar:

// ❌ NUNCA HAGAS ESTO
const salt = Math.random().toString(36).substring(2);

Math.random() no es criptográficamente seguro. Es predecible. Un atacante que conozca el timestamp de creación del usuario podría adivinar el salt.

La forma correcta usa la Web Crypto API:

function generateSalt(bytes = 16) {
  // Uint8Array crea un array de bytes
  const array = new Uint8Array(bytes);
  
  // getRandomValues llena el array con bytes aleatorios criptográficamente seguros
  crypto.getRandomValues(array);
  
  // Convertimos a hexadecimal para almacenarlo en la base de datos
  // Cada byte se convierte en 2 caracteres hex, así que 16 bytes = 32 caracteres
  return Array.from(array)
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

// Ejemplo de uso
const salt = generateSalt(16);
console.log(salt); // "a3f7c2e1849d5e6b8f0a1c2d3e4f5a6b"

Detrás de escenas, crypto.getRandomValues() usa el generador de números aleatorios del sistema operativo (/dev/urandom en Linux, CryptGenRandom en Windows), que recoge entropía de fuentes físicas como el ruido térmico del hardware.

Paso 2: Preparar la contraseña para la Web Crypto API

La Web Crypto API trabaja con objetos CryptoKey, no con strings directamente. Necesitamos "importar" la contraseña como material de clave:

async function importKey(password) {
  const encoder = new TextEncoder();
  
  return crypto.subtle.importKey(
    'raw',                              // Formato: bytes crudos
    encoder.encode(password),           // Convertimos string a Uint8Array
    { name: 'PBKDF2' },                 // Algoritmo que vamos a usar
    false,                              // No queremos exportar esta clave
    ['deriveBits']                      // Solo necesitamos derivar bits
  );
}

Este paso es necesario porque la Web Crypto API está diseñada para ser segura por defecto. No puedes pasar strings directamente a operaciones criptográficas; tienes que declarar explícitamente qué vas a hacer con la clave.

Paso 3: El corazón del algoritmo - PBKDF2

Aquí es donde todo cobra sentido. La función deriveBits implementa PBKDF2 según el estándar RFC 2898:

async function pbkdf2SHA256(password, salt, iterations = 100000, hashBytes = 32) {
  const encoder = new TextEncoder();
  
  // Primero importamos la contraseña como clave
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    encoder.encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveBits']
  );

  // Luego derivamos los bits usando PBKDF2
  const derivedBits = await crypto.subtle.deriveBits(
    {
      name: 'PBKDF2',
      salt: encoder.encode(salt),     // El salt que generamos antes
      iterations: iterations,        // Número de rondas (ej: 100000)
      hash: 'SHA-256',               // Función hash interna
    },
    keyMaterial,                     // La contraseña como material de clave
    hashBytes * 8                    // Bits a derivar (32 bytes = 256 bits)
  );

  // Convertimos ArrayBuffer a string hexadecimal
  return Array.from(new Uint8Array(derivedBits))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

Este es exactamente el código que usa nuestra herramienta de SHA-256. Puedes verificarlo comparando los resultados.

Paso 4: Sistema completo de registro y login

Ahora juntamos todo en un sistema real. Este es un patrón que uso en prácticamente todos mis proyectos con autenticación:

// Al registrar un nuevo usuario
async function hashPassword(password) {
  // Generamos salt único para este usuario
  const salt = generateSalt(16);
  
  // Calculamos el hash con PBKDF2
  const hash = await pbkdf2SHA256(password, salt, 100000, 32);
  
  // Guardamos ambos en la base de datos
  // El salt NO es secreto, se guarda junto al hash
  return { salt, hash };
}

// Cuando el usuario intenta hacer login
async function verifyPassword(inputPassword, storedSalt, storedHash) {
  // Recalculamos el hash con el mismo salt
  const hash = await pbkdf2SHA256(inputPassword, storedSalt, 100000, 32);
  
  // Comparación de tiempo constante para prevenir timing attacks
  // (En producción, usa una librería que ya implemente esto)
  return hash === storedHash;
}

// Ejemplo de flujo completo
async function registerUser(email, password) {
  const { salt, hash } = await hashPassword(password);
  
  // Guardar en tu base de datos
  await db.users.create({
    email,
    passwordHash: hash,
    passwordSalt: salt,
    createdAt: new Date()
  });
}

async function loginUser(email, inputPassword) {
  const user = await db.users.findOne({ email });
  if (!user) return { success: false, error: 'Usuario no encontrado' };
  
  const isValid = await verifyPassword(
    inputPassword, 
    user.passwordSalt, 
    user.passwordHash
  );
  
  if (!isValid) {
    return { success: false, error: 'Contraseña incorrecta' };
  }
  
  // Generar JWT o sesión
  return { success: true, user };
}

Fíjate en algo importante: el salt se guarda junto al hash en la base de datos. No hay problema en que un atacante sepa el salt de un usuario. El salt solo existe para asegurar que dos usuarios con la misma contraseña tengan hashes diferentes.


Versión para Node.js: la API nativa

Si estás construyendo un backend con Node.js, tienes dos opciones. Puedes usar la Web Crypto API (disponible desde Node.js 15), o puedes usar el módulo crypto nativo que tiene una implementación más directa:

const { pbkdf2, randomBytes } = require('crypto');
const { promisify } = require('util');

// Convertimos las funciones de callback a Promises
const pbkdf2Async = promisify(pbkdf2);
const randomBytesAsync = promisify(randomBytes);

async function hashPasswordNode(password) {
  // randomBytes genera bytes aleatorios criptográficamente seguros
  const saltBuffer = await randomBytesAsync(16);
  const salt = saltBuffer.toString('hex');
  
  // pbkdf2 es la implementación nativa de Node.js
  const derivedKey = await pbkdf2Async(
    password,        // Contraseña
    salt,            // Salt
    100000,          // Iteraciones
    32,              // Bytes de salida
    'sha256'         // Algoritmo de hash
  );
  
  return { 
    salt, 
    hash: derivedKey.toString('hex') 
  };
}

async function verifyPasswordNode(password, salt, storedHash) {
  const derivedKey = await pbkdf2Async(
    password, salt, 100000, 32, 'sha256'
  );
  
  return derivedKey.toString('hex') === storedHash;
}

La API nativa de Node.js es ligeramente más rápida porque está implementada en C++ directamente, sin la capa de abstracción de la Web Crypto API.


Consideraciones de producción: lo que la documentación no te cuenta

Después de años implementando sistemas de autenticación, hay algunas lecciones que he aprendido por las malas:

1. Timing attacks son reales

La comparación hash === storedHash que mostré arriba es vulnerable a timing attacks. Un atacante puede medir el tiempo que tarda tu servidor en responder y adivinar la contraseña byte a byte.

En producción, usa una librería como bcrypt o argon2 que ya implementen comparación de tiempo constante, o implementa tu propia función:

function timingSafeEqual(a, b) {
  if (a.length !== b.length) return false;
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return result === 0;
}

2. Considera usar bcrypt o Argon2 directamente

PBKDF2 es el estándar y funciona bien, pero hay alternativas modernas diseñadas específicamente para contraseñas:

  • bcrypt: Basado en Blowfish, incluye salt automáticamente
  • Argon2: Ganador del Password Hashing Competition, resistente a ataques con GPU

La ventaja de implementar PBKDF2 tú mismo es que entiendes exactamente qué está pasando. La desventaja es que es más propenso a errores.

3. Migra iteraciones gradualmente

Cuando necesites aumentar las iteraciones (cosa que deberías hacer cada par de años), no forces a todos los usuarios a cambiar contraseña. Implementa "upgrading on login":

async function loginUser(email, password) {
  const user = await db.users.findOne({ email });
  const isValid = await verifyPassword(password, user.salt, user.hash);
  
  if (isValid && user.iterations < CURRENT_ITERATIONS) {
    // Re-hash con más iteraciones
    const { salt, hash } = await hashPassword(password, CURRENT_ITERATIONS);
    await db.users.update(user.id, { salt, hash, iterations: CURRENT_ITERATIONS });
  }
  
  return isValid;
}

Experimenta sin escribir código

Si quieres entender visualmente cómo funciona todo esto antes de implementarlo, nuestra herramienta SHA-256 con PBKDF2 te permite:

  • Introducir cualquier texto y ver su hash
  • Generar salts aleatorios con un click
  • Cambiar el número de iteraciones y ver cómo el hash cambia
  • Copiar el código de ejemplo para usar en tu proyecto

Es una forma excelente de experimentar y entender el algoritmo sin escribir una sola línea de código.

También puedes leer nuestra guía completa de Hashing para entender el contexto más amplio de las funciones hash y sus usos.


Checklist para implementar en producción

Antes de desplegar tu sistema de autenticación, asegúrate de:

☐ Usar salt aleatorio único por usuario

  • Generado con CSPRNG (crypto.getRandomValues o crypto.randomBytes)
  • Mínimo 16 bytes (128 bits)
  • Nunca reutilizar el mismo salt

☐ Usar suficientes iteraciones

  • Mínimo 100.000 para desarrollo
  • Mínimo 600.000 para producción (recomendación OWASP 2024)
  • Considerar aumentar cada 2 años

☐ Comparar hashes de forma segura

  • Usar timing-safe comparison
  • Nunca usar === directamente en producción

☐ Almacenar salt junto al hash

  • En la misma fila de la base de datos
  • No es secreto, no necesita cifrado adicional

☐ Nunca usar para otra cosa

  • PBKDF2 es para contraseñas, no para hashing general
  • Para tokens de sesión, usa HMAC
  • Para integridad de archivos, usa SHA-256 directo

☐ Tener un plan de migración

  • Cómo aumentar iteraciones sin forzar cambio de contraseña
  • Upgrading on login
  • Monitorear tiempos de login para detectar problemas de rendimiento

Conclusión: la seguridad está en los detalles

Implementar autenticación segura no es difícil, pero es fácil hacerlo mal. La diferencia entre SHA-256 simple y PBKDF2 con salt e iteraciones es la diferencia entre un sistema que cae en horas frente a un ataque y uno que resistiría siglos.

El conocimiento que has adquirido en este tutorial es aplicable no solo a JavaScript, sino a cualquier lenguaje y plataforma. Los principios son universales: salt único por usuario, iteraciones para ralentizar ataques, y algoritmos estándar bien probados.

La próxima vez que veas en un foro a alguien preguntando "¿SHA-256 es seguro para contraseñas?", ya sabrás la respuesta completa: el algoritmo es seguro, pero el uso es incorrecto. Y podrás explicar por qué.

#sha256#pbkdf2#seguridad#javascript#criptografía

Artículos relacionados