Ir al contenido
HexaTransfer
Volver al blog
Analisis tecnicos

Cifrado de archivos en JavaScript: tutorial paso a paso

Cifra archivos en el navegador con JavaScript: tutorial práctico sobre cifrado AES, derivación de claves y manejo seguro de archivos.

JavaScript puede cifrar un archivo completamente en el navegador usando la Web Crypto API, sin ninguna intervención del servidor. La AEPD, en su guía sobre medidas de seguridad bajo el RGPD, identifica el cifrado de extremo a extremo como una de las salvaguardas técnicas más efectivas para proteger datos personales en tránsito y en reposo. El pipeline estándar: leer el archivo como ArrayBuffer, derivar una clave AES-GCM de 256 bits de una contraseña mediante PBKDF2-SHA256 (600.000 iteraciones), cifrar con un IV aleatorio de 12 bytes y empaquetar la sal, el IV y el ciphertext en un Blob descargable. Todos los navegadores principales admiten esto de forma nativa a través de window.crypto.subtle, y para archivos de hasta 2-3 GB el proceso completo tarda menos de diez segundos en un portátil moderno sin tocar ninguna librería de terceros.

Las decisiones de algoritmo que importan

Elige AES-GCM, no AES-CBC. GCM te da cifrado autenticado en un solo paso, detectando manipulaciones con una etiqueta de 128 bits, mientras que CBC necesita un paso HMAC separado que la mayoría de tutoriales implementan mal. Usa una clave de 256 bits: la diferencia de rendimiento frente a 128 bits es insignificante en hardware con AES-NI. Elige PBKDF2-SHA256 para la derivación de claves basada en contraseña, a menos que puedas enviar Argon2id vía WebAssembly, que es más robusto pero añade 50 KB de peso de descarga.

Evita: el modo ECB (fundamentalmente roto), el relleno manual (una década de ataques de oráculo de relleno CBC), MD5 o SHA-1 (vulnerables a colisiones), y cualquier cosa del paquete crypto-js sin entender que usa CBC con PKCS7 de forma predeterminada.

Leer un archivo en memoria

La API File te da tres formas de obtener bytes:

const buf = await file.arrayBuffer();          // archivo completo
const stream = file.stream();                  // en streaming
const text = await file.text();                // decodificado UTF-8

Para archivos de más de 500 MB, arrayBuffer() suele fallar en Safari móvil. Usa streaming:

async function* chunks(file, size = 4 * 1024 * 1024) {
  for (let off = 0; off < file.size; off += size) {
    yield new Uint8Array(await file.slice(off, off + size).arrayBuffer());
  }
}

Cada slice se lee de forma perezosa desde el disco, así que el pico de memoria permanece acotado.

Derivar una clave a partir de la contraseña del usuario

Nunca pases una contraseña en bruto a encrypt. Primero deriva una clave:

async function deriveKey(password, salt) {
  const enc = new TextEncoder();
  const material = await crypto.subtle.importKey(
    'raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

Genera una sal fresca de 16 bytes por archivo con crypto.getRandomValues(new Uint8Array(16)). Almacena la sal junto al ciphertext: reutilizar una sal entre archivos anula el propósito de PBKDF2. La hoja de consejos de almacenamiento de contraseñas de OWASP recomienda actualmente 600.000 iteraciones para PBKDF2-SHA256, lo que equivale a unos 500 ms de derivación de claves en un teléfono de gama media.

Cifrar el archivo

Con una clave disponible, el cifrado es una sola llamada a subtle.encrypt por buffer:

async function encryptBuffer(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv, tagLength: 128 },
    key,
    plaintext
  );
  return { iv, ciphertext };
}

GCM falla catastróficamente si reutilizas un par (IV, clave): la colisión del flujo de clave filtra ambos textos claros. Un IV aleatorio de 96 bits ofrece aproximadamente 2^48 cifrados seguros con una clave, lo que es suficiente para cifrado de archivos. Si cifras muchos fragmentos con la misma clave, deriva el IV de un contador más un prefijo aleatorio de 32 bits.

Empaquetar la salida

El descifrador necesita la sal, el IV y el ciphertext. Empaquétalos en un único blob con una pequeña cabecera:

function pack(salt, iv, ciphertext) {
  const magic = new TextEncoder().encode('ENC1');
  return new Blob([magic, salt, iv, new Uint8Array(ciphertext)]);
}

Versiona la cabecera (ENC1, ENC2...) para poder migrar algoritmos más adelante sin romper archivos antiguos. Ofrece una descarga mediante:

const url = URL.createObjectURL(packed);
const a = document.createElement('a');
a.href = url; a.download = `${file.name}.enc`; a.click();
URL.revokeObjectURL(url);

Descifrar archivos

El descifrado invierte el proceso y lanza OperationError si la contraseña es incorrecta o el archivo fue manipulado:

async function decryptFile(blob, password) {
  const buf = await blob.arrayBuffer();
  const view = new Uint8Array(buf);
  const magic = new TextDecoder().decode(view.slice(0, 4));
  if (magic !== 'ENC1') throw new Error('Formato desconocido');
  const salt = view.slice(4, 20);
  const iv = view.slice(20, 32);
  const ct = view.slice(32);
  const key = await deriveKey(password, salt);
  const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
  return new Blob([pt]);
}

Muestra un único mensaje de error: "no se pudo descifrar: contraseña incorrecta o archivo corrupto", en lugar de distinguir entre fallo de etiqueta y fallo estructural. Eso elimina la filtración de información similar al oráculo de relleno hacia posibles atacantes.

Cifrar archivos grandes sin quedarse sin RAM

Para cualquier cosa de más de 500 MB, no llames a arrayBuffer() en el archivo completo. Cifra fragmentos de forma independiente con IVs únicos derivados de un contador:

function ivForChunk(baseIV, index) {
  const iv = new Uint8Array(baseIV);
  const view = new DataView(iv.buffer);
  view.setUint32(8, index, false);
  return iv;
}

Pasa el archivo por un TransformStream, cifra cada fragmento de 4 MB y escribe los resultados en un WritableStream apuntando al disco mediante la File System Access API. El pico de memoria se mantiene cerca de 10 MB incluso para un archivo de 20 GB. El formato fragmentado necesita registrar el tamaño y el recuento de fragmentos en su cabecera para que el descifrador pueda reensamblar correctamente.

Errores habituales que llegan a producción

Tres patrones aparecen en revisiones de código criptográfico JavaScript:

Primero, almacenar la clave en bruto en localStorage por comodidad. localStorage es síncrono, limitado por origen y legible por cualquier XSS. Usa en su lugar una CryptoKey no exportable en IndexedDB.

Segundo, usar Math.random() para IVs o sales. Math.random() es predecible; usa siempre crypto.getRandomValues.

Tercero, asumir que subtle.encrypt es de tiempo constante. Lo es en las implementaciones nativas del navegador, pero cualquier envoltorio JavaScript alrededor de ella casi seguramente no lo es. Mantén tu propio código fuera del camino crítico.

HexaTransfer aplica exactamente este pipeline: PBKDF2 a 600.000 iteraciones, AES-256-GCM, fragmentos en streaming, para que el servidor solo almacene ciphertext opaco. Pruébalo en https://hexatransfer.com — gratuito, sin cuenta, hasta 10 GB.

Probar la implementación

Escribe un arnés de prueba que haga un viaje de ida y vuelta con un blob aleatorio de 10 MB, mute un byte y verifique que el descifrado lanza una excepción. Añade fuzzing contra cabeceras malformadas y ciphertexts truncados: el bug habitual son comprobaciones de límites faltantes en slice() que se bloquean en lugar de rechazar. Mide el rendimiento en un iPhone SE, un Android de gama media y un Chromebook; cualquier cosa que tarde más de 2 segundos de derivación de claves es demasiado lento para usuarios móviles. Solicita una revisión de un segundo par de ojos antes de pasar a producción: el código criptográfico parece simple y falla de formas sutiles que las pruebas pasan por alto.

Envía archivos grandes de forma segura con cifrado de extremo a extremo

Transfiere archivos de hasta 10 GB gratis con cifrado de extremo a extremo. Sin necesidad de cuenta. Tus archivos se cifran en tu navegador antes de subirlos: nadie más puede leerlos.

Enviar un archivo