Criptografía en Node.js: hashing, cifrado y confianza desde cero

Dos llaves antiguas y ornamentadas cruzadas sobre una superficie de piedra oscura, sirviendo como metáfora de la seguridad y el cifrado en el post "Criptografía en Node.js: hashing, JWT y cifrado híbrido" del blog de orbitant. Esta imagen fue generada por IA.

¿Cuántas veces has usado JWT, HTTPS o verificado el hash de un binario sin entender realmente qué estaba pasando por debajo? La criptografía está en todas partes en el desarrollo moderno y entender su mecánica cambia la forma en que diseñas sistemas.

Dos investigadores, una red hostil y ninguna garantía de que el mensaje llegue sin ser leído ni manipulado. Este es el punto de partida perfecto para entender la criptografía en Node.js: no como magia negra matemática, sino como un conjunto de herramientas pragmáticas con las que ya trabajas cada día sin saberlo.

Y como queríamos aprender más sobre criptografía, pedimos a Ulises Gascón, Node.js Core Collaborator, releaser del runtime y colaborador activo en seguridad del ecosistema JavaScript, que nos impartiera una Knowledge Sharing, es decir, una de nuestras sesiones de formación semanales. Así, usando la historia de Alice y Bob, Ulises desarrolló una completa sesión, recogida ahora en este artículo con código real de la librería crypto de Node.js y ejemplos que reconocerás de tu trabajo diario.

Qué resuelve la criptografía (y qué no)

Antes de escribir una sola línea de código, conviene entender el problema que estamos resolviendo. Imagina a Alice y Bob, dos investigadores destinados en bases distintas de la Antártida. Necesitan coordinarse a diario, pero su única vía de comunicación es una red de nodos intermedios que no controlan y en los que no confían. Cualquier mensaje que envíen podría ser leído, modificado o suplantado por un tercero. Cuando Alice quiere enviar un mensaje a Bob a través de esos nodos hostiles, tiene cuatro objetivos:

  1. Confidencialidad: que solo Bob pueda leer el mensaje.
  2. Integridad: que el mensaje llegue exactamente como fue enviado, sin modificaciones.
  3. No repudio: que Alice no pueda negar haber enviado el mensaje.
  4. Autenticación: que Bob pueda verificar que el mensaje viene de Alice y no de un tercero suplantando su identidad.

Cada uno de estos objetivos corresponde a un tipo de ataque real. Sin confidencialidad, cualquier nodo intermedio puede leer tus datos en claro —esta es la razón por la que nunca deberías enviar credenciales por HTTP—. Sin integridad, un nodo hostil puede modificar un paquete en tránsito sin que el receptor lo detecte: imagina que el mensaje «temperatura estable, continuar experimento» llega como «temperatura crítica, evacuar base». Sin no repudio, Alice podría enviar una orden y luego negar haberlo hecho, ya que no hay forma de demostrar criptográficamente quién fue el autor. Y sin autenticación, no tienes forma de saber si el mensaje realmente viene de Alice o de alguien que la está suplantando.

Si resuelves estos cuatro puntos, da igual que el mensaje pase por nodos completamente hostiles. Incluso si lo publicaras en un periódico, solo Bob podría entenderlo y, además, podría demostrar que fue Alice quien lo escribió.

Esto es exactamente lo que hacemos, sin ser conscientes de ello, cada vez que abrimos un candado verde en el navegador, instalamos una dependencia de npm o nos autenticamos con un JWT. La criptografía no elimina la necesidad de confiar: la traslada de los intermediarios a las matemáticas.

Diagrama de flujo generado por IA que ilustra el envío de un mensaje de Alice a Bob a través de tres nodos intermediarios, representando la arquitectura de red y criptografía en Node.js para el blog de Orbitant.

Hashing: la base de la integridad en criptografía en Node.js

Una función de hash toma cualquier entrada, ya sean 3 letras o 8 gigas de datos, y produce siempre una salida de longitud fija. Es determinista, es decir, el mismo input produce siempre el mismo output, e irreversible, de manera que no puedes recuperar el original a partir del hash. Por último, es sensible a cambios mínimos: cambiar una sola letra produce un hash completamente distinto.

La librería crypto de Node.js expone estas funciones directamente:

const { createHash } = require('crypto');

const generateHash = (txt, algorithm) => {
  return createHash(algorithm).update(txt).digest('hex');
};

const generateChecksum = (txt) => ({
  md5: generateHash(txt, 'md5'),
  sha1: generateHash(txt, 'sha1'),
  sha256: generateHash(txt, 'sha256'),
});

generateChecksum('Just a simple Text');
/*
{
  md5: '42fc79f713ed18f0aff045c8a86eb14c',
  sha1: '4ca3e243ede34c908fd3a9ec971d4b4eb3ff4da5',
  sha256: '838af5f849c8a7b5eb8c7021feb47c53836bda5f183228194e3373b5f245c7a5'
}
*/

¿Para qué sirve esto en la práctica? Para verificar la integridad de ficheros (checksum), generar identificadores únicos y evitar duplicados en sistemas distribuidos. Ojo: estas funciones de hash rápidas (MD5, SHA-1, SHA-256) no son adecuadas para almacenar contraseñas. Para este fin necesitas funciones de key derivation lentas por diseño como scrypt, bcrypt o argon2, como verás a continuación.

El problema de las rainbow tables

Aquí llega el primer susto: si almacenas contraseñas con un hash rápido sin salt —da igual que sea MD5 o SHA-256—, eres vulnerable a los ataques de rainbow table. La mecánica es directa: alguien genera un diccionario gigante de contraseñas comunes (1234, password, qwerty) junto con su hash calculado. Cuando accede a tu base de datos, solo tiene que buscar coincidencias en ese diccionario.

La solución es el salt: un valor aleatorio que se añade a la contraseña antes de hashear. Esto garantiza que dos usuarios con la misma contraseña tengan hashes distintos en base de datos, y que un ataque de diccionario sea computacionalmente inviable.

const { scryptSync, randomBytes, timingSafeEqual } = require('crypto');

const password = 'mi-password-123';

// Mismo password, salt diferente → hash diferente cada vez
const salt1 = randomBytes(16);
const salt2 = randomBytes(16);

const hash1 = scryptSync(password, salt1, 64);
const hash2 = scryptSync(password, salt2, 64);

console.log('Hash 1:', hash1.toString('hex'));
// Hash 1: 5f4dcc3b5aa765d61d8327deb882cf99...
console.log('Hash 2:', hash2.toString('hex'));
// Hash 2: 4812d0e8b9704c741907fe1a11820a2e...
console.log('¿Iguales?', hash1.equals(hash2));
// ¿Iguales? false

// Pero ambos se verifican correctamente con su salt
const check1 = scryptSync(password, salt1, 64);
const check2 = scryptSync(password, salt2, 64);

console.log('¿Hash 1 válido?', timingSafeEqual(hash1, check1)); // true
console.log('¿Hash 2 válido?', timingSafeEqual(hash2, check2)); // true

Fíjate en que el snippet usa timingSafeEqual en lugar de === para comparar hashes. La razón es que === sale del bucle en cuanto encuentra un carácter distinto, lo que abre la puerta a un timing attack: midiendo cuánto tarda el servidor en responder, un atacante puede ir adivinando caracteres uno a uno. timingSafeEqual siempre tarda lo mismo, independientemente de cuántos caracteres coincidan.

Para saber qué algoritmos de hashing usar en cada contexto —y cuáles evitar— la guía de almacenamiento de contraseñas de OWASP es la referencia más completa y actualizada.

HMAC y JWT: autenticación de mensajes

El hashing resuelve la integridad, pero no la autenticación. Necesitas una forma de decir: «Este mensaje lo escribí yo, y puedes comprobarlo.» Ahí entra HMAC (Hash-based Message Authentication Code).

La idea es simple: en lugar de usar un salt aleatorio, usas una clave secreta compartida. El resultado es un hash que solo puede verificar quien conoce esa clave.

const { createHmac, timingSafeEqual } = require('crypto');

const secret = 'clave-secreta-compartida';

function hmac(message, key) {
  return createHmac('sha256', key).update(message).digest('hex');
}

// Alice envía mensaje + MAC
const msg = 'Suministros al punto Delta';
const mac = hmac(msg, secret);

const packet = { msg, mac };
console.log('Paquete enviado:', JSON.stringify(packet, null, 2));
// {
//   "msg": "Suministros al punto Delta",   ← texto plano, cualquiera lo lee
//   "mac": "a7f3b2c1d9e8..."               ← generado con secreto
// }

// Bob verifica
const bobMac = hmac(packet.msg, secret);
const valid = timingSafeEqual(Buffer.from(packet.mac), Buffer.from(bobMac));
console.log('¿Auténtico?', valid); // true

El mensaje viaja en texto plano, por lo que cualquiera puede leerlo. Sin la clave secreta, nadie puede generar un HMAC válido. Esto es lo que garantiza integridad y autenticación, no confidencialidad.

¿Y qué pasa si un atacante intercepta el mensaje, lo modifica e intenta falsificar el HMAC con una clave inventada?

// Un atacante lee el mensaje, lo modifica e intenta falsificar
const fakePacket = {
  msg: 'Suministros al punto Gamma',
  mac: hmac('Suministros al punto Gamma', 'clave-inventada'),
};

const fakeValid = timingSafeEqual(
  Buffer.from(fakePacket.mac),
  Buffer.from(bobMac)
);
console.log('¿Falso auténtico?', fakeValid); // false

¿Te suena? Es exactamente la mecánica que hay detrás de los JSON Web Tokens (jwt.io es una buena referencia visual para inspeccionarlos). Un JWT tiene tres partes:

  • Header: el algoritmo usado (por ejemplo, HS256 = HMAC + SHA-256).
  • Payload: los datos del usuario (claims, roles, expiración).
  • Firma: el HMAC del header + payload calculado con tu clave secreta.

Un JWT no es un mensaje cifrado. Su contenido es público y cualquiera puede decodificar el payload en Base64. Lo que garantiza es que ese contenido no ha sido modificado y que lo emitió quien posee la clave secreta. Nunca incluyas información sensible en el payload de un JWT sin cifrado adicional.

Si alguien cambia admin: false a admin: true en el payload, la firma ya no coincide con la que generó el servidor. El token queda inválido. El servidor, que posee la clave secreta, detecta la manipulación al recalcular la firma y compararla con la recibida.

La especificación RFC 7519 define con precisión cómo deben estructurarse los JWT, cuándo tienen que expirar y cómo rotarlos. Vale la pena leerla antes de implementar cualquier sistema de autenticación basado en tokens.

Captura de jwt.io mostrando las tres partes de un JWT con colores diferenciados (header, payload y firma) para el post sobre criptografía en Node.js de Orbitant.
Captura de pantalla de jwt.io

Entender esta estructura es fundamental para implementar flujos de autenticación robustos. Como complemento visual de lo anterior, en este análisis rápido sobre el funcionamiento de JWT se detalla con precisión cómo interactúan estas tres partes para validar la identidad del usuario sin comprometer la escalabilidad del sistema.

Cifrado simétrico, asimétrico e híbrido

Con hashing y HMAC tienes integridad y autenticación, pero el mensaje sigue siendo legible para cualquiera que lo intercepte. Para garantizar la confidencialidad, necesitas cifrado.

Simétrico: rápido, con un punto débil conocido

El cifrado simétrico usa la misma clave para cifrar y descifrar. Es extremadamente rápido, ideal para grandes volúmenes de datos, y la librería crypto de Node.js lo soporta con AES:

const { createCipheriv, createDecipheriv, randomBytes } = require('crypto');

const key = randomBytes(32);
const iv = randomBytes(12);

// Cifrar
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted =
  cipher.update('Hola Antártida', 'utf8', 'hex') + cipher.final('hex');
const tag = cipher.getAuthTag();
console.log(encrypted);
// 6fdf19e6077e165cfd869d385e1964

// Descifrar
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
console.log(decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8'));
// 'Hola Antártida'

El Initialization Vector (IV) juega el mismo papel que el salt en el hashing: garantiza que dos mensajes idénticos generen outputs cifrados distintos. Esto dificulta el análisis de patrones y evita vulnerabilidades como la que permitió romper Enigma en la Segunda Guerra Mundial: los operadores siempre enviaban la misma secuencia meteorológica al principio de cada mensaje, lo que daba a los criptoanalistas un punto de anclaje.

El problema del cifrado simétrico es la distribución de la clave. Si Alice y Bob están en puntos distintos de la Antártida y solo se comunican por canales no confiables, ¿cómo acuerdan la clave sin exponerla a los nodos hostiles que hay entre ellos?

Asimétrico: distribuido por diseño

El cifrado asimétrico usa dos claves matemáticamente relacionadas:

  • Clave pública: la compartes con todo el mundo. Cuanta más gente la tenga, mejor.
  • Clave privada: nunca sale de tu máquina. Si la pierdes, pierdes el acceso.

Lo que cifras con la clave pública de alguien, solo lo puede descifrar esa persona con su clave privada. Y lo que firmas con tu clave privada, cualquiera puede verificarlo con tu clave pública — sin necesidad de haber tenido contacto previo contigo.

La contrapartida es el rendimiento: el cifrado asimétrico es órdenes de magnitud más lento y tiene un límite en el tamaño de los datos que puede procesar directamente (unos pocos cientos de bytes en RSA-2048, dependiendo del padding utilizado).

const { generateKeyPairSync, publicEncrypt, privateDecrypt } = require('crypto');

const { publicKey, privateKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding:  { type: 'spki',  format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

console.log(publicKey);
// -----BEGIN PUBLIC KEY-----
// MIIBIjANBg(......)2n7sH5XoVh
// -----END PUBLIC KEY-----

// Cifrar con pública, descifrar con privada
const encrypted = publicEncrypt(publicKey, Buffer.from('Hola Antártida'));
console.log(encrypted.toString('base64'));
// VFODA0sb1+40(...)+glzZtzMoSV/oZ134w==

console.log(privateDecrypt(privateKey, encrypted).toString());
// 'Hola Antártida'

Híbrido: lo que usa todo el mundo en producción

La solución estándar combina los dos. Es lo que hace PGP, y es lo que hace HTTPS por debajo en cada sesión TLS:

  1. Generas una clave simétrica de un solo uso para este mensaje.
  2. Cifras el mensaje con esa clave simétrica (rápido, sin límite de tamaño).
  3. Cifras la clave simétrica con la clave pública del destinatario (seguro, pequeño).
  4. Envías ambas cosas juntas.

El destinatario usa su clave privada para descifrar la clave simétrica, y con esa clave descifra el mensaje. Resultado: la velocidad del cifrado simétrico con la seguridad de distribución de claves del asimétrico.

Criptografía en el flujo de trabajo diario: commits, releases y cadenas de confianza

Todo esto no es teoría de libro. Está en tu flujo de trabajo diario, aunque no lo veas.

Cuando Ulises firma una release de Node.js, calcula el SHA de cada binario y firma ese resumen con su clave PGP privada. Puedes ver un ejemplo real en el fichero SHASUMS256.txt.asc de Node.js 22. Si te descargas ese binario y verificas la firma con su clave pública GPG, puedes comprobar dos cosas de forma independiente al servidor de distribución:

  • El binario no ha sido modificado desde que salió de su máquina.
  • Fue el autor quien lo generó, no un intermediario que comprometió el servidor.

Puedes comprobarlo tú mismo con unos pocos comandos:

# Importar la clave pública del releaser (Ulises Gascón)
gpg --keyserver hkps://keys.openpgp.org --recv-keys A363A499291CBBC940DD62E41F10027AF002F8B0

# Descargar los checksums firmados
curl -O https://nodejs.org/dist/v22.15.0/SHASUMS256.txt.asc

# Verificar la firma
gpg --verify SHASUMS256.txt.asc

# Descargar el binario
curl -O https://nodejs.org/dist/v22.15.0/node-v22.15.0-linux-x64.tar.xz

# Verificar la integridad del binario contra el checksum
grep node-v22.15.0-linux-x64.tar.xz SHASUMS256.txt.asc | sha256sum --check

La página de verificación de binarios de Node.js documenta el proceso completo con las claves de cada releaser.

Lo mismo aplica a los commits de GitHub. El historial de Git es una cadena de hashes, pero eso no garantiza que quien aparece como autor sea quien realmente hizo el commit. Firmar tus commits con GPG añade esa capa de verificación:

git config --global commit.gpgsign true
git config --global user.signingkey TU_KEY_ID

Con esto, GitHub muestra la etiqueta «Verified» en tus commits y cualquiera puede comprobar criptográficamente que ese commit salió de tu máquina.

Etiqueta Verified en un commit firmado de GitHub. Captura de pantalla de la release 22.15.0 de Node.js en GitHub
Captura de pantalla de la release 22.15.0 de Node.js en GitHub

Es especialmente relevante en proyectos open source con muchos contribuidores, o en entornos donde el historial tiene valor legal o de auditoría. La documentación de GnuPG cubre la generación y gestión de claves con detalle, y esta guía práctica de backend.cafe lo explica paso a paso.

Y la cadena de confianza de HTTPS funciona de la misma manera: tu navegador confía en unos certificados raíz, que avalan a certificados intermedios, que a su vez avalan al certificado de cada dominio. Si algún eslabón falla —porque tu sistema no se ha actualizado en años y el certificado raíz ha expirado— la cadena se rompe. El famoso candado verde no es más que el extremo visible de toda esta maquinaria.

La pregunta de si la computación cuántica romperá todo esto es legítima. La respuesta es más compleja de lo que puede parecer: llegará un momento en que, para algunos algoritmos, sí. Por ejemplo, algoritmos como RSA o las curvas elípticas que usamos hoy en cifrado asimétrico no son resistentes a ataques cuánticos en su forma actual. Ya existen algoritmos diseñados para ser post-quantum, y ya hay actores que recopilan tráfico cifrado ahora mismo para descifrarlo cuando tengan capacidad cuántica suficiente. No es ciencia ficción, es ingeniería con horizonte de planificación largo.

Lo que no va a cambiar es la mecánica conceptual. Hashing, simétrico, asimétrico, híbrido… llevan décadas siendo los mismos bloques de construcción. La implementación evoluciona; los algoritmos se actualizan; los parámetros de seguridad se ajustan. Pero entender la mecánica de verdad es lo que te permite elegir el algoritmo correcto para cada contexto, detectar cuando alguien ha tomado un atajo peligroso y no caer en la trampa de pensar que un JWT es un mensaje cifrado solo porque la URL tiene un candado verde.

Preguntas frecuentes

¿Puedo usar MD5 para algo en 2026?

Sí, pero no para contraseñas. MD5 es rápido y tiene colisiones conocidas, lo que lo hace inadecuado para almacenamiento de credenciales. Para verificar la integridad de un fichero grande en un contexto de baja criticidad — comparar dos backups internos, por ejemplo — sigue siendo útil por su velocidad. Para contraseñas, usa bcrypt, scrypt o argon2.

¿Un JWT es seguro para datos del usuario?

Depende de qué entiendes por «seguro». El payload de un JWT es público — cualquiera puede decodificarlo en Base64, aunque no pueda modificarlo sin invalidar la firma. Nunca metas contraseñas, tokens de acceso de terceros ni información personal sensible en el payload sin cifrado adicional. Lo que un JWT garantiza es integridad y autenticidad, no confidencialidad.

¿Merece la pena firmar los commits de Git?

En proyectos open source con muchos contribuidores, entornos regulados o infraestructura crítica, sí. En proyectos internos con un equipo pequeño y acceso controlado al repositorio, el beneficio es menor — pero el coste de configurarlo es mínimo y el hábito es valioso.

Sigue nuestro viaje construyendo agentes IA desde dentro