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.

Publicado por HexaTransfer, servicio de Gaprod — Groupe Dwenola SAS. Estas guías presentan nuestro servicio junto con otros productos. Sobre el editor

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 genera una clave AES-256-GCM aleatoria mediante Web Crypto. No la deriva de la contraseña opcional. El fragmento tras # se separa antes de la petición HTTP, como explica RFC 3986. La contraseña es un control de acceso distinto cuyo hash guarda el servidor. AES-GCM aporta autenticación; este formato no añade un HMAC separado.

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

Envía hasta 50 GiB gratis con cifrado de extremo a extremo, sin cuenta ni correo electrónico. Elige de 1 a 30 días; 14 por defecto.

Enviar un archivo