🔐Ciberseguridad

Rotación de claves criptográficas: estrategias para sistemas en producción

Felipe••18 min de lectura
Diseña una rotación de claves con AES-256-GCM y keyId en Node.js: transición, re-cifrado, KMS, recuperación y respuesta ante compromiso.

Cambiar la clave con la que una aplicación cifra información parece una operación pequeña: generar otra y actualizar la configuración. El problema aparece al intentar abrir un documento guardado antes del cambio. La clave nueva no puede descifrarlo y, si eliminamos la anterior demasiado pronto, podríamos perderlo de forma definitiva.

Rotar una clave exige coordinar nuevas escrituras, lecturas históricas y recuperación. También exige saber si estamos renovando una clave por política o reaccionando ante un posible robo: las decisiones de confianza son distintas.

Construiremos un ejemplo local con Node.js y AES-256-GCM. Tendrá una clave activa para cifrar, claves anteriores habilitadas solo para descifrar y un formato que indique qué clave necesita cada registro. Después veremos cómo llevar ese diseño a una transición de producción sin confundir el ejemplo con un gestor de claves completo.

Qué cambia cuando rotamos una clave

Una clave criptográfica es material secreto que interviene en una operación criptográfica. En AES-256 tiene 256 bits: 32 bytes. El identificador clave-a que usaremos no contiene esos bytes ni permite reconstruirlos; solo sirve para seleccionar una clave conocida.

La rotación introduce material nuevo y cambia qué clave se autoriza para nuevas operaciones. No transforma automáticamente el contenido ya cifrado. Para que un documento deje de depender de su clave anterior, normalmente habrá que descifrarlo y cifrarlo de nuevo, o aplicar una operación equivalente del servicio criptográfico utilizado.

Esto es diferente de codificar un dato o calcular su hash. Nuestro objetivo es poder recuperar información, por lo que la conservación y disponibilidad de las claves forman parte del diseño.

El ciclo de vida: generar no significa activar

NIST SP 800-57 Parte 1, revisión 5 describe estados y transiciones de las claves en su sección 7. La siguiente tabla los traduce a decisiones de una aplicación que almacena datos cifrados:

Etapa operativaQué permiteQué debemos comprobar
Generación y preparaciónCrear material nuevo y registrarlo, sin usarlo todavía sobre datosAleatoriedad criptográfica, propósito, propietario, identificador y custodia
ActivaciónAutorizar el uso de la claveLectores preparados, permisos y configuración coherente
Uso activoCifrar nuevos registros y descifrar los correspondientesLímites de uso, acceso y trazabilidad
Desactivación para nuevas escriturasDescifrar datos anteriores durante la transiciónNingún escritor debe seguir eligiéndola para cifrar
Retiro del servicio habitualReservar acceso excepcional si quedan necesidades de recuperaciónMigración, respaldos, retención y permisos restringidos
DestrucciónHacer irrecuperable el material que ya no se necesitaTodas sus copias, incluidas las de recuperación

«Retiro del servicio habitual» es una etapa de nuestra operación, no el nombre de un estado formal adicional de NIST. La publicación también contempla suspensión y compromiso. Una clave desactivada para cifrar no equivale a una clave destruida ni a una comprometida.

NIST distingue el período durante el que se aplica protección de aquel durante el que se procesa información ya protegida. En nuestro caso, podemos dejar de cifrar con una clave antes de dejar de descifrar con ella. Ese segundo período necesita una justificación y un final; no debería convertirse en conservación indefinida por falta de inventario. Véanse las secciones 5.3 y 7.4 del documento completo.

Cuándo rotar: política y volumen, además del calendario

El criptoperíodo es el intervalo durante el que una clave está autorizada para usarse. No hay una frecuencia que podamos copiar sin conocer el sistema.

Para establecerla, debemos valorar el algoritmo y su modo de uso, la sensibilidad y vida útil de la información, cuántas operaciones realizan todas las instancias, cómo se protege el material y qué consecuencias tendría perderlo o exponerlo. También cuentan las obligaciones aplicables y la capacidad de completar una transición sin errores. Estos factores aparecen en NIST SP 800-57, sección 5.3.

La OWASP Key Management Cheat Sheet recomienda documentar una política basada en riesgos y considerar límites de uso, además del tiempo. Sus orientaciones no convierten una frecuencia concreta en una obligación legal universal. Si una norma o contrato exige algo, hay que identificar su versión y alcance.

Con GCM, el volumen importa especialmente por los IV o nonces, valores que no deben repetirse con la misma clave. Usaremos IV aleatorios de 96 bits; aleatorio no significa matemáticamente irrepetible. En producción hay que presupuestar el número de cifrados por clave y sumar todas las instancias que la comparten. La sección 8.3 de NIST SP 800-38D establece un máximo de 2³² invocaciones para la construcción aleatoria que usamos; es un techo, no una meta ni una garantía que sustituya el análisis de riesgo.

Rotación programada y sospecha de compromiso

En una rotación programada, partimos de que la clave anterior conserva su confianza. Podemos preparar lectores, cambiar las escrituras y migrar datos gradualmente:

flowchart TD
    A[Preparar nueva clave y lectores] --> B[Activar nueva clave para cifrar]
    B --> C[Leer datos antiguos con su keyId]
    C --> D[Re-cifrar por lotes con la clave nueva]
    D --> E[Verificar datos y recuperación]
    E --> F[Retirar la clave anterior]
    F --> G[Destruir cuando ya no sea necesaria]

Ante sospecha de compromiso, primero hay que contener el acceso: detener el uso para nuevas escrituras, revisar identidades y permisos, aislar los componentes afectados y generar la sustituta desde un entorno confiable. Guardar una clave nueva en el mismo entorno que sigue controlando un atacante no resuelve el incidente.

El acceso a la clave anterior, si es imprescindible para recuperar información, debe quedar excepcionalmente restringido y supervisado. No corresponde conservarla como una clave de lectura ordinaria sin revisar el riesgo. NIST trata este caso por separado en las secciones 5.5 y 7.5 de SP 800-57.

Hay dos consecuencias que conviene tener claras:

  • Re-cifrar no borra lo que alguien ya copió. Si obtuvo una clave y los textos cifrados correspondientes, una rotación posterior no recupera la confidencialidad de esas copias.
  • Una clave GCM comprometida también permite fabricar datos con tags válidos. Descifrar y re-cifrar todo sin revisar su procedencia puede dar continuidad a una alteración. La recuperación necesita evidencia independiente: respaldos confiables anteriores al incidente, registros protegidos o verificaciones del negocio, según el caso.

Estas consecuencias se derivan del modelo de cifrado autenticado: su garantía presupone que el adversario no conoce la clave. El ejemplo siguiente modela una transición programada; no automatiza la respuesta a un incidente.

Un sobre con versión, identificador y contexto

Llamaremos sobre al objeto JSON que guarda los datos cifrados y sus metadatos. Este es nuestro formato didáctico, no un estándar de intercambio:

CampoFunción
vVersión del formato y de sus reglas de interpretación
keyIdIdentificador de una clave permitida por el servidor
ivNonce de 12 bytes, representado en Base64
tagTag de autenticación de 16 bytes, representado en Base64
ciphertextContenido cifrado, representado en Base64

Cambiar la clave no obliga a cambiar v. Todos nuestros registros usan el formato 1, aunque unos indiquen clave-a y otros clave-b. Si más adelante cambiamos el algoritmo o las reglas de AAD, tendremos que diseñar otra versión y rechazar las que no entendamos.

El keyId será público, estable y no reutilizable para otro material. No será una URL, una ruta de archivo ni una instrucción para buscar claves arbitrarias. Tampoco probaremos todas las claves hasta que alguna funcione: seleccionaremos exactamente una del catálogo autorizado.

Los AAD, datos adicionales autenticados, vincularán el contenido con el propósito del formato, su versión, el keyId y un contexto esperado. Por ejemplo, la empresa y el identificador del documento. Ese contexto lo construye el servidor desde el recurso autorizado, no desde campos que el cliente pueda elegir libremente.

Esto ayuda a detectar el traslado de un sobre a otro recurso. No impide por sí solo restaurar una versión antigua válida del mismo registro: la protección contra repetición requiere control de versiones o estado confiable adicional. Tampoco reemplaza la autorización por objeto.

Si necesitas repasar el papel del tag y los AAD, el artículo sobre AES-256-GCM, IV y nonce desarrolla esos fundamentos.

Ejemplo ejecutable en Node.js

Necesitas Node.js 22 o superior. No hay dependencias externas. Crea una carpeta de laboratorio y guarda los dos archivos siguientes.

Las claves serán efímeras y estarán en un Map privado del módulo. Esta decisión permite observar la transición sin introducir secretos reales. No es un almacén para producción: al terminar el proceso se pierde el acceso al material; no guardes información que necesites recuperar después. Tampoco expongas las funciones administrativas como endpoints públicos.

El módulo de cifrado y transición

Guarda este archivo como rotacion.mjs:

import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const claves = new Map();
let keyIdActivo = null;

const MAX_DATOS = 64 * 1024;
const MAX_SOBRE = 90_000;
const ID_VALIDO = /^[a-z0-9-]{1,64}$/;

export function generarClave(keyId) {
  if (typeof keyId !== 'string' || !ID_VALIDO.test(keyId) || claves.has(keyId)) {
    throw new Error('Identificador inválido o ya utilizado');
  }

  claves.set(keyId, { material: randomBytes(32), estado: 'preparada' });
}

export function activarClave(keyId) {
  const siguiente = claves.get(keyId);

  if (!siguiente || siguiente.estado !== 'preparada') {
    throw new Error('La clave debe estar preparada');
  }

  if (keyIdActivo !== null) {
    claves.get(keyIdActivo).estado = 'descifrar';
  }

  siguiente.estado = 'activa';
  keyIdActivo = keyId;
}

export function bloquearClave(keyId) {
  const clave = claves.get(keyId);

  if (!clave) throw new Error('Clave desconocida');

  clave.estado = 'bloqueada';

  if (keyIdActivo === keyId) keyIdActivo = null;
}

function crearAAD(keyId, contexto) {
  if (typeof contexto !== 'string' || contexto.length === 0 ||
      Buffer.byteLength(contexto, 'utf8') > 256) {
    throw new Error('Contexto inválido');
  }

  return Buffer.from(JSON.stringify(['uncodigo:rotacion-aes-gcm', 1, keyId, contexto]));
}

function leerBase64(valor, maxBytes) {
  if (typeof valor !== 'string' || valor.length > 4 * Math.ceil(maxBytes / 3)) {
    throw new Error('Base64 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, contexto) {
  const keyId = keyIdActivo;
  const clave = claves.get(keyId);

  if (!clave || clave.estado !== 'activa') {
    throw new Error('No hay una clave activa para cifrar');
  }

  if (!Buffer.isBuffer(datos) || datos.length > MAX_DATOS) {
    throw new Error('Se requiere un Buffer de hasta 64 KiB');
  }

  const aad = crearAAD(keyId, contexto);
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', clave.material, iv, { authTagLength: 16 });

  cipher.setAAD(aad);

  const ciphertext = Buffer.concat([cipher.update(datos), cipher.final()]);

  return JSON.stringify({
    v: 1,
    keyId,
    iv: iv.toString('base64'),
    tag: cipher.getAuthTag().toString('base64'),
    ciphertext: ciphertext.toString('base64'),
  });
}

export function descifrar(serializado, contextoEsperado) {
  try {
    if (typeof serializado !== 'string' || serializado.length > MAX_SOBRE) {
      throw new Error('Sobre inválido');
    }

    const sobre = JSON.parse(serializado);
    const campos = ['v', 'keyId', 'iv', 'tag', 'ciphertext'];

    if (!sobre || Array.isArray(sobre) || typeof sobre !== 'object' ||
        Object.keys(sobre).length !== campos.length ||
        !campos.every(campo => Object.hasOwn(sobre, campo)) ||
        sobre.v !== 1 || typeof sobre.keyId !== 'string' || !ID_VALIDO.test(sobre.keyId)) {
      throw new Error('Formato no admitido');
    }

    const clave = claves.get(sobre.keyId);

    if (!clave || !['activa', 'descifrar'].includes(clave.estado)) {
      throw new Error('Clave no disponible para descifrar');
    }

    const iv = leerBase64(sobre.iv, 12);
    const tag = leerBase64(sobre.tag, 16);
    const ciphertext = leerBase64(sobre.ciphertext, MAX_DATOS);

    if (iv.length !== 12 || tag.length !== 16) {
      throw new Error('Longitud inválida');
    }

    const decipher = createDecipheriv('aes-256-gcm', clave.material, iv, {
      authTagLength: 16,
    });

    decipher.setAAD(crearAAD(sobre.keyId, contextoEsperado));
    decipher.setAuthTag(tag);

    const parcial = decipher.update(ciphertext);
    const final = decipher.final();

    return Buffer.concat([parcial, final]);
  } catch {
    throw new Error('No se pudo descifrar el registro');
  }
}

El algoritmo es fijo, las longitudes se comprueban y el formato rechaza campos inesperados. El límite de 64 KiB pertenece a este ejemplo, no al límite general de AES-GCM.

La clave se genera con randomBytes(32) y cada cifrado utiliza un IV nuevo de 12 bytes. La activación deja automáticamente la anterior en estado descifrar. Esa etiqueta expresa una política del módulo: los bytes de una clave AES no pierden la capacidad matemática de cifrar. Por eso en producción el permiso debe imponerse también en el servicio y en las identidades que pueden acceder al material.

Al descifrar, update() puede producir bytes antes de comprobar la autenticidad. No los devolvemos ni procesamos hasta que final() termina correctamente. Este comportamiento está documentado en Node.js: decipher.final() y decipher.setAuthTag().

El error público es genérico. En un servicio real, conserva diagnósticos internos acotados para distinguir un formato inválido, una clave inaccesible o un fallo de autenticación, sin exponer datos ni material criptográfico al cliente o a los logs.

Cambiar la clave sin perder las lecturas anteriores

Guarda ahora demo.mjs:

import assert from 'node:assert/strict';
import { activarClave, bloquearClave, cifrar, descifrar, generarClave } from './rotacion.mjs';

const contexto = JSON.stringify(['empresa-demo', 'documento-42']);
const datos = Buffer.from('Documento ficticio', 'utf8');

generarClave('clave-a');
activarClave('clave-a');

const antiguo = cifrar(datos, contexto);
assert.equal(JSON.parse(antiguo).keyId, 'clave-a');

generarClave('clave-b');
activarClave('clave-b');

const nuevo = cifrar(datos, contexto);
assert.equal(JSON.parse(nuevo).keyId, 'clave-b');
assert.deepEqual(descifrar(antiguo, contexto), datos);

// Migración de un registro; cada cifrado genera un IV nuevo.
const migrado = cifrar(descifrar(antiguo, contexto), contexto);
assert.equal(JSON.parse(migrado).keyId, 'clave-b');
assert.deepEqual(descifrar(migrado, contexto), datos);

const manipulado = JSON.parse(migrado);
const tag = Buffer.from(manipulado.tag, 'base64');
tag[0] ^= 1;
manipulado.tag = tag.toString('base64');

assert.throws(() => descifrar(JSON.stringify(manipulado), contexto));
assert.throws(() => descifrar(migrado, JSON.stringify(['empresa-demo', 'otro-documento'])));

bloquearClave('clave-a');

assert.throws(() => descifrar(antiguo, contexto));
assert.deepEqual(descifrar(migrado, contexto), datos);
assert.deepEqual(descifrar(nuevo, contexto), datos);

console.log('Transición, migración y rechazos comprobados');

Ejecuta:

node demo.mjs

Si todas las comprobaciones pasan, verás Transición, migración y rechazos comprobados. El ejemplo comprueba que el dato antiguo se lee tras activar la nueva clave, que la migración conserva el contenido y que la alteración del tag o del contexto provoca un rechazo. Tras bloquear la clave anterior, su copia antigua deja de ser legible por este módulo, mientras las copias nuevas siguen funcionando.

El bloqueo no destruye los bytes de la clave. Borrar una entrada de un Map, terminar un proceso o sobrescribir un Buffer tampoco acredita la eliminación de todas las copias posibles en memoria. Para limpiar el laboratorio, borra los dos archivos; el programa no persiste claves ni documentos.

Este programa es deliberadamente de un solo proceso y bajo volumen. No implementa custodia durable, coordinación entre instancias, presupuesto global de nonces ni recuperación. Llevarlo a producción exige resolver esas responsabilidades mediante el gestor de claves y la arquitectura del sistema, no convertir este Map en un archivo de secretos.

Cómo hacer una transición en producción

La siguiente secuencia es una propuesta de diseño para una rotación programada. Debe adaptarse a la base de datos y al servicio de claves utilizado:

  1. Inventariar dependencias. Localiza registros por keyId, servicios escritores, lectores, colas, exportaciones y respaldos. Incluye procesos poco frecuentes: un informe mensual puede seguir usando una configuración antigua.
  2. Preparar la nueva clave y los lectores. Los lectores deben resolver tanto el identificador anterior como el nuevo antes de que aparezcan registros nuevos. Comprueba permisos de recuperación y una restauración representativa.
  3. Cambiar las escrituras. Activa el nuevo identificador y confirma que todas las instancias dejaron de cifrar con el anterior. Un cambio de variable en una sola réplica no completa esta etapa.
  4. Migrar datos históricos. Re-cifra por lotes limitados, con progreso persistido, reintentos controlados y capacidad de pausa.
  5. Verificar y retirar. Comprueba qué dependencias quedan, reduce permisos de la clave anterior y prueba las rutas de recuperación antes de destruir nada.

La configuración también necesita consistencia. Nuestro keyIdActivo funciona en un proceso; en un despliegue distribuido hacen falta una fuente de configuración autorizada, propagación observable y una política para escritores desactualizados. Si no se puede obtener la clave activa, falla la escritura de forma controlada: no generes otra silenciosamente ni vuelvas a una clave retirada.

Re-cifrado sin pisar actualizaciones concurrentes

Para cada fila, el trabajador puede leer id, revisión y sobre; descifrar con el contexto esperado; cifrar con la clave activa y guardar el resultado solo si la revisión no cambió. Esta escritura condicional, también llamada compare-and-swap, evita reemplazar una actualización que otro proceso hizo durante la migración.

Si hay conflicto, vuelve a leer y decide si todavía necesita migración. Si el registro ya usa la clave objetivo, omítelo. Así el trabajo puede reanudarse sin re-cifrar indefinidamente lo que ya terminó. Cada nuevo intento de cifrado genera un nonce nuevo, aunque después la escritura condicional falle.

Conviene registrar cantidades procesadas, pendientes, conflictos y errores, sin guardar el texto claro. No omitas silenciosamente un registro que no se pudo descifrar: podría señalar una clave ausente, corrupción o manipulación y requiere investigación.

La migración al leer puede complementar un proceso por lotes, pero no termina el trabajo si existen documentos que nadie consulta. Define además qué ocurre con las copias de seguridad: conservar una copia antigua cifrada puede exigir mantener su clave en un entorno de recuperación restringido. Cero filas antiguas en la base activa no demuestra que ya sea seguro destruir la clave.

Para revertir un despliegue de aplicación, conserva una versión que entienda los nuevos sobres y tenga los permisos necesarios. Volver al código anterior no debería obligar a reactivar una clave comprometida ni dejar ilegibles las escrituras nuevas.

KMS, HSM y separación del material criptográfico

Un KMS, Key Management Service, administra claves y sus operaciones o acceso mediante políticas. Un HSM, Hardware Security Module, es un dispositivo especializado que protege material criptográfico y puede ejecutar operaciones sin exportar determinadas claves. Un KMS puede apoyarse en HSM; no son sinónimos.

La guía de gestión de claves de OWASP recomienda custodia especializada y mantener el material fuera de repositorios y artefactos de compilación. Nuestro laboratorio con Node.js no acredita cumplimiento FIPS ni sustituye un módulo validado cuando ese requisito aplica.

En un diseño de cifrado por envoltura, distinguimos:

  • DEK, Data Encryption Key: cifra los datos.
  • KEK, Key Encryption Key: protege la DEK cuando se almacena o transporta cifrada.

El JSON del laboratorio solo es un contenedor de metadatos; no implementa esa segunda capa de envoltura de claves. En producción, guardar una DEK cifrada junto al documento puede ser válido si la KEK permanece protegida y el acceso para desenvolverla está separado. Guardar la DEK en claro junto al documento elimina esa separación.

Cambiar la KEK y volver a envolver la misma DEK no cambia los bytes de esta última ni reinicia sus límites de uso. Si lo que necesitas sustituir es la DEK, debes migrar los datos que dependen de ella. Esta distinción también aparece en OWASP: rotación.

Comprueba la semántica del proveedor. Por ejemplo, AWS KMS documenta que rotar su material no re-cifra los datos ni rota las claves de datos generadas anteriormente. Es un comportamiento concreto, no una regla que podamos atribuir a todos los servicios. Nuestro keyId identifica un material inmutable; un proveedor puede mantener un identificador lógico estable y gestionar versiones internas.

Separar el material también implica separar responsabilidades: entorno de desarrollo y producción, aplicaciones y propósitos. NIST SP 800-57, sección 5.2, recomienda un propósito por clave; el cifrado autenticado constituye una operación conjunta válida, pero esa clave no debería reutilizarse además para firmar JWT o proteger otros secretos.

Una identidad con permiso para descifrar no debería poder eliminar claves por defecto. Y un KMS no neutraliza una aplicación comprometida que conserva permiso para solicitar descifrados: el mínimo privilegio sigue siendo parte del control.

Pérdida, auditoría y destrucción

No puedes derivar una clave perdida a partir de su keyId, de la clave nueva ni del contenido cifrado. Si necesitas conservar datos durante años, necesitas conservar una vía autorizada y probada para recuperar sus claves durante ese período. NIST SP 800-57 trata respaldo, archivo y recuperación en su sección 8.

Prueba restauraciones que incluyan tanto datos como acceso a las versiones correctas. Documenta quién puede recuperar una clave, mediante qué autorización y cómo se audita. Una copia de seguridad que existe pero no puede descifrarse no satisface la recuperación del servicio.

Para este diseño, un evento de auditoría útil puede identificar el keyId, la operación, la identidad del servicio, la fecha, el resultado y la referencia del cambio autorizado. Los registros deben quedar protegidos contra alteración y acceso indebido. No incluyas claves, documentos en claro, credenciales ni respuestas completas del gestor. Los mismos criterios de logs seguros de una API se aplican a la infraestructura criptográfica.

Vigila escritores que siguen usando identificadores antiguos, intentos de acceso a claves bloqueadas y aumentos de errores de autenticación. Un error no demuestra por sí solo un ataque: también puede revelar una migración incompleta o un permiso incorrecto. La auditoría debe permitir distinguir esos casos.

Finalmente, destruir exige revisar todas las copias necesarias y existentes, incluidas las de archivo y respaldo. Esa es la finalidad de NIST SP 800-57, sección 8.3.4. Usa el procedimiento del servicio o dispositivo correspondiente y verifica su resultado; eliminar un alias o cambiar una variable no equivale a destruir el material.

Una rotación termina cuando conocemos sus dependencias

El keyId permite seleccionar la clave correcta; el versionado permite interpretar el sobre; los AAD vinculan el contenido con su contexto. Juntos facilitan una transición que podemos probar, observar y detener si algo falla.

La decisión más delicada llega después del cambio de escrituras: saber qué datos y respaldos todavía necesitan la clave anterior. Una política útil conecta ese inventario con el re-cifrado, la recuperación y el retiro. Ante compromiso, añade una revisión explícita de la confianza en los datos y en el entorno que hará la recuperación.

Referencias

#rotación de claves#AES-256-GCM#gestión de claves#Node.js#KMS#NIST SP 800-57

Artículos relacionados