Cifrar un dato resuelve una pregunta: ¿cómo impedir que lo lea alguien que no tiene la clave? Pero al recuperarlo aparece otra: ¿cómo saber si fue modificado? AES-GCM permite abordar ambas con cifrado autenticado.
En esta guía construiremos un formato pequeño y versionado para cifrar y descifrar datos con node:crypto. Veremos qué papel cumple cada pieza antes de escribir el código. No necesitas conocimientos previos de criptografía; sí poder ejecutar un archivo de Node.js.
Qué significa AES-256-GCM
AES significa Advanced Encryption Standard, o estándar de cifrado avanzado. Es un algoritmo simétrico: quien cifra y quien descifra utilizan la misma clave secreta. 256 indica el tamaño de esa clave: 256 bits, equivalentes a 32 bytes. No es el tamaño del mensaje, del nonce ni del tag. AES sigue trabajando internamente con bloques de 128 bits, incluso cuando la clave tiene 256 bits. NIST FIPS 197.
GCM significa Galois/Counter Mode. Es el modo que combina el cifrado con una comprobación de autenticidad e integridad. Además admite datos que se autentican sin cifrarlos. De ahí la sigla AEAD, Authenticated Encryption with Associated Data: cifrado autenticado con datos asociados. NIST SP 800-38D.
En nuestro ejemplo hay cuatro elementos que conviene distinguir:
| Elemento | Tamaño elegido | Papel en el ejemplo |
|---|---|---|
| Clave | 32 bytes, 256 bits | Secreto necesario para cifrar y descifrar |
| IV o nonce | 12 bytes, 96 bits | Identifica una operación de cifrado bajo esa clave |
| Authentication tag | 16 bytes, 128 bits | Permite comprobar la autenticidad del conjunto protegido |
| AAD | Variable | Contexto autenticado que no necesita ocultarse |
La clave se mantiene secreta. El nonce y el tag pueden viajar junto al contenido cifrado. El tamaño de clave y el del tag son decisiones distintas: AES-256 no significa que el tag tenga 256 bits.
IV y nonce: por qué no se deben repetir
IV significa Initialization Vector, o vector de inicialización. En GCM cumple el papel de un nonce: un valor que no debe repetirse al cifrar con la misma clave. El perfil de este artículo usa 96 bits, la longitud recomendada por NIST para simplificar el diseño y favorecer interoperabilidad y eficiencia. SP 800-38D, sección 5.2.1.1.
Reutilizar la pareja clave/nonce repite la secuencia que GCM combina con los datos para cifrarlos. Dos mensajes distintos dejan entonces una relación observable entre sus contenidos; conocer uno puede revelar información del otro. También se compromete la protección de autenticidad, permitiendo falsificaciones. No es un problema que se resuelva escogiendo una clave más larga. RFC 5116, sección 5.1.1.
La regla se aplica a nuevas operaciones de cifrado. Descifrar varias veces un registro con su nonce original es normal.
Aleatorio no significa imposible de repetir
El laboratorio generará cada nonce con randomBytes(12). Eso no prueba unicidad: una colisión sigue siendo posible. NIST limita a 2³² operaciones de cifrado por clave cuando se utiliza su construcción basada en generación aleatoria, contando todas las instancias que comparten esa clave. Es un máximo bajo ese marco, no una meta de uso ni una garantía universal de seguridad. SP 800-38D, secciones 8.2 y 8.3.
Como decisión de operación, fijaría un presupuesto de uso inferior al límite aplicable y rotaría la clave antes de agotarlo. Ese control debe abarcar todos los procesos, no reiniciarse con cada despliegue. El ejemplo local no implementa ese control distribuido.
Otra estrategia es una construcción determinista con un identificador de emisor y un contador persistente. Exige coordinar emisores, evitar desbordamientos y sobrevivir a reinicios o restauraciones sin repetir valores. Un contador en memoria o una fecha en milisegundos no ofrecen esas garantías. RFC 5116, sección 3.2.
Authentication tag: comprobar antes de entregar datos
El authentication tag, o etiqueta de autenticación, es el valor que permite verificar el contenido protegido y sus datos asociados. Aquí fijaremos 16 bytes tanto al cifrar como al descifrar; no aceptaremos tags truncados.
Hay una advertencia importante en Node.js: decipher.update() puede producir bytes antes de que se compruebe su autenticidad. No deben enviarse, guardarse ni utilizarse hasta que decipher.final() termine correctamente. Si falla la autenticación, se descarta el resultado. Documentación de Decipher.
Un tag válido no identifica a una persona: cualquiera que posea la clave compartida puede generar mensajes válidos. Tampoco sustituye la autorización para acceder a un registro.
Estado de la recomendación: la revisión de SP 800-38D sigue en preparación. En su consulta previa de junio de 2026, NIST anunció que retiraría el soporte de tags inferiores a 96 bits y aclaró que todavía no había un documento de borrador disponible para esa consulta. Esa intención de revisión no debe presentarse como una nueva edición final ya publicada. Nuestro ejemplo usa 128 bits. NIST: segunda consulta previa de SP 800-38D Rev. 1.
AAD: asociar el contenido a su contexto
AAD significa Additional Authenticated Data: datos adicionales autenticados. Participan en la comprobación de autenticidad, pero no se cifran. Ambos extremos deben utilizar exactamente los mismos bytes. SP 800-38D, sección 5.2.1.1.
Para nuestro diseño, imagina una nota con identificador 42. Autenticaremos el contexto nota:42. Si alguien copia su contenido cifrado al registro 43, el descifrado con el contexto esperado nota:43 fallará.
La aplicación debe construir ese contexto a partir del registro al que el usuario está autorizado a acceder. Leer un identificador del propio paquete y confiar en él no consigue esa vinculación: el atacante podría trasladar el paquete completo.
AAD tampoco evita por sí solo una repetición o replay, es decir, volver a presentar un mensaje válido anterior. En este diseño, una versión antigua de la misma nota conserva el contexto nota:42. Si necesitas rechazarla, debes comprobar además una revisión esperada o un estado persistente; cifrar no sustituye esa lógica.
Un formato versionado, pequeño y explícito
Llamaremos sobre al objeto que agrupa los datos necesarios para recuperar el contenido. Nuestro formato JSON tendrá exactamente estos campos:
| Campo | Contenido |
|---|---|
v | Versión numérica del formato; inicialmente 1 |
iv | Nonce en Base64 |
tag | Etiqueta de autenticación en Base64 |
ciphertext | Bytes cifrados en Base64 |
La versión 1 fija AES-256-GCM, nonce de 12 bytes y tag de 16 bytes. No aceptamos un nombre de algoritmo enviado por el cliente. La clave se entrega por separado a la función, y el contexto esperado también.
Autenticaremos la secuencia ['uncodigo:aes-gcm', 1, contexto], serializada con JSON y codificada en UTF-8. Así la versión y el propósito del formato forman parte del AAD. El orden queda definido; no dependeremos de concatenaciones ambiguas como unir campos sin separadores.
Este es un formato propio del laboratorio, no un estándar de intercambio. Elegimos un máximo de 64 KiB de contenido, 256 bytes de contexto y 90 000 bytes para el JSON recibido. Son límites de esta aplicación para acotar trabajo y memoria, no límites generales de AES-GCM. En una API también habría que limitar el cuerpo de la petición antes de cargarlo completo.
Laboratorio completo con Node.js
Necesitas Node.js 22 o superior. No hay dependencias externas. Crea una carpeta local y los dos archivos indicados; utiliza únicamente los datos ficticios del ejemplo.
1. Cifrado, validación y descifrado
Guarda lo siguiente en cifrado.mjs:
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITMO = 'aes-256-gcm';
const VERSION = 1;
const TAG_BYTES = 16;
const MAX_DATOS = 64 * 1024;
const MAX_SOBRE = 90_000;
function validarClave(clave) {
if (!Buffer.isBuffer(clave) || clave.length !== 32) {
throw new TypeError('La clave debe ser un Buffer de 32 bytes');
}
}
function crearAAD(contexto) {
if (typeof contexto !== 'string' || contexto.length === 0
|| Buffer.byteLength(contexto, 'utf8') > 256) {
throw new TypeError('Contexto inválido');
}
// Mismo orden y codificación en ambos extremos.
return Buffer.from(JSON.stringify(['uncodigo:aes-gcm', VERSION, contexto]), 'utf8');
}
function leerBase64(valor, maxBytes) {
if (typeof valor !== 'string' || valor.length > 4 * Math.ceil(maxBytes / 3)) {
throw new Error('Campo inválido');
}
const bytes = Buffer.from(valor, 'base64');
if (bytes.length > maxBytes || bytes.toString('base64') !== valor) {
throw new Error('Base64 no canónico');
}
return bytes;
}
export function cifrar(datos, clave, contexto) {
validarClave(clave);
if (!Buffer.isBuffer(datos) || datos.length > MAX_DATOS) {
throw new TypeError('Los datos deben ser un Buffer de hasta 64 KiB');
}
const aad = crearAAD(contexto);
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITMO, clave, iv, { authTagLength: TAG_BYTES });
cipher.setAAD(aad);
const ciphertext = Buffer.concat([cipher.update(datos), cipher.final()]);
const tag = cipher.getAuthTag();
return JSON.stringify({
v: VERSION,
iv: iv.toString('base64'),
tag: tag.toString('base64'),
ciphertext: ciphertext.toString('base64'),
});
}
export function descifrar(serializado, clave, contextoEsperado) {
try {
validarClave(clave);
if (typeof serializado !== 'string'
|| Buffer.byteLength(serializado, 'utf8') > MAX_SOBRE) {
throw new Error('Sobre inválido');
}
const sobre = JSON.parse(serializado);
if (sobre === null || typeof sobre !== 'object' || Array.isArray(sobre)
|| Object.keys(sobre).sort().join(',') !== 'ciphertext,iv,tag,v'
|| sobre.v !== VERSION) {
throw new Error('Formato o versión no admitidos');
}
const iv = leerBase64(sobre.iv, 12);
const tag = leerBase64(sobre.tag, TAG_BYTES);
const ciphertext = leerBase64(sobre.ciphertext, MAX_DATOS);
if (iv.length !== 12 || tag.length !== TAG_BYTES) {
throw new Error('Longitudes inválidas');
}
const aad = crearAAD(contextoEsperado);
const decipher = createDecipheriv(ALGORITMO, clave, iv, {
authTagLength: TAG_BYTES,
});
decipher.setAAD(aad);
decipher.setAuthTag(tag);
// update() puede producir datos que todavía NO están autenticados.
const provisional = decipher.update(ciphertext);
const final = decipher.final();
// Solo devolvemos datos después de que final() verifique el tag.
return Buffer.concat([provisional, final]);
} catch {
throw new Error('No se pudo autenticar y descifrar el contenido');
}
}El orden de las llamadas importa: setAAD() precede a update(), getAuthTag() se ejecuta después de finalizar el cifrado y setAuthTag() entrega el tag recibido antes de verificar el descifrado. authTagLength: 16 fija el tamaño aceptado. API de node:crypto.
leerBase64() vuelve a codificar los bytes y compara el resultado con la entrada. Es una regla de nuestro formato para rechazar representaciones alternativas o malformadas: el decodificador Base64 de Node.js admite, por ejemplo, espacios en blanco. Base64 representa bytes; no añade protección criptográfica. Documentación de Buffer.
El descifrado utiliza un mensaje de error único para formato inválido, clave incorrecta o fallo de autenticación. No devuelve datos parciales ni intenta descifrar con otro algoritmo. Ese mensaje uniforme no garantiza tiempos idénticos ni sustituye los controles de una API; los errores operativos deben poder investigarse sin registrar claves ni contenido sensible.
2. Recuperar el mensaje y rechazar una modificación
Guarda esto en demo.mjs, junto al archivo anterior:
import { randomBytes } from 'node:crypto';
import { cifrar, descifrar } from './cifrado.mjs';
// Clave efímera: solo para este laboratorio local.
const clave = randomBytes(32);
const contexto = 'nota:42';
const original = Buffer.from('Mensaje ficticio para el laboratorio', 'utf8');
const sobre = cifrar(original, clave, contexto);
const recuperado = descifrar(sobre, clave, contexto);
console.log('Contenido recuperado:', recuperado.equals(original));
const alterado = JSON.parse(sobre);
const tag = Buffer.from(alterado.tag, 'base64');
tag[0] ^= 1;
alterado.tag = tag.toString('base64');
try {
descifrar(JSON.stringify(alterado), clave, contexto);
process.exitCode = 1;
console.error('Fallo de la prueba: se aceptó un tag modificado');
} catch (error) {
console.log('Manipulación rechazada:', error.message);
}Ejecuta:
node demo.mjsLa ejecución del laboratorio produjo:
Contenido recuperado: true
Manipulación rechazada: No se pudo autenticar y descifrar el contenidoLa clave se crea en memoria y se pierde al terminar el proceso. Esto es deliberado para el laboratorio: en una aplicación que guarda datos debes conservar la clave de forma segura para recuperarlos después. No conviertas una contraseña o una cadena predecible en una supuesta clave AES de 32 bytes. Si el objetivo es verificar contraseñas de usuarios, consulta la guía de almacenamiento con Argon2id.
Además de esta demostración, se ejecutaron pruebas de contenido vacío, UTF-8 y tamaño máximo; modificaciones del IV, tag y ciphertext; clave o contexto incorrectos; versiones no admitidas; Base64 inválido y tamaños fuera del límite. Todos esos casos se comportaron como se esperaba. Generar nonces distintos en una muestra pequeña no demuestra que jamás vayan a repetirse.
Para limpiar el laboratorio, elimina sus dos archivos. La demostración no escribe claves ni mensajes en disco.
Cómo evolucionar el formato sin perder acceso a los datos
Mantendría estas reglas al integrar el ejemplo:
- Versión conocida o rechazo. Una futura versión debe definir algoritmo, tamaños y serialización del AAD. No adivines el formato ni pruebes algoritmos hasta que alguno funcione.
- Clave fuera del sobre. Guarda las claves separadas de los datos cifrados, con controles de acceso y recuperación. OWASP recomienda mecanismos de gestión de claves apropiados al entorno y contemplar su rotación. OWASP Cryptographic Storage Cheat Sheet.
- Identificador de clave cuando haga falta. Una versión futura puede incorporar
kid, abreviatura de key identifier. Sería un identificador público para localizar una clave autorizada, no la clave misma. Debe validarse contra un conjunto permitido y autenticarse como parte del AAD. - Migración deliberada. Para volver a cifrar con una nueva clave, verifica primero el registro antiguo, genera un nonce nuevo y escribe el nuevo sobre de forma controlada. Conserva acceso a las claves antiguas mientras existan datos o copias de respaldo que las necesiten.
Los puntos sobre versionado y estructura son decisiones de este diseño; no una exigencia de que NIST utilice JSON o estos nombres de campo.
Lo que debe permanecer cierto al recuperar un dato
La revisión del código debe poder responder cuatro preguntas: qué clave se usa, cómo se evita repetir nonces bajo esa clave, qué contexto se autentica y en qué momento se entrega el contenido descifrado.
En este ejemplo, el dato solo sale de descifrar() cuando la verificación termina correctamente. Esa propiedad, junto con la gestión del nonce y de la clave, importa más que añadir capas de código alrededor de una llamada a AES.
Referencias
- NIST FIPS 197: algoritmo AES y tamaños de clave y bloque.
- NIST SP 800-38D y documento completo: GCM, AAD, IV y restricciones de uso.
- NIST SP 800-38D Rev. 1: consulta previa de junio de 2026: estado de revisión y cambios propuestos.
- RFC 5116: interfaz AEAD, formación de nonces y consecuencias de reutilizarlos.
- Node.js 22: Crypto: API y advertencias de cifrado autenticado.
- Node.js 22: Buffer: codificaciones y conversiones de bytes.
- OWASP Cryptographic Storage Cheat Sheet: gestión de claves y almacenamiento cifrado.
