Ir al contenido
HexaTransfer
Volver al blog
Cifrado y seguridad

Optimización del rendimiento del cifrado: cripto rápido en el navegador

Optimiza el rendimiento del cifrado para transferencias grandes. Cifrado streaming, Web Workers y técnicas de procesamiento por fragmentos.

Cifrar un archivo de 5 GB en un navegador sin bloquear la interfaz requiere un conjunto específico de técnicas: Web Crypto API para AES-256-GCM acelerado por hardware (1-2 GB/s con AES-NI), procesamiento por fragmentos de 1-4 MB para acotar la memoria, Web Workers para mantener el hilo principal responsivo, lecturas en streaming mediante File.stream() en lugar de FileReader.readAsArrayBuffer(), y una gestión cuidadosa de nonces para que los fragmentos puedan cifrarse en paralelo. Las bibliotecas de cripto JavaScript puro son 10-20 veces más lentas que Web Crypto y deben reservarse para primitivos que el navegador no expone de forma nativa. Aquí tienes cómo alcanzar tasas de cientos de megabytes por segundo en hardware de usuario real.

Mide primero, optimiza después

Antes de optimizar, mide. En un MacBook Air M2 de 2024 con Chrome 120, cifrar un buffer de 1 GB con AES-256-GCM mediante Web Crypto en un bucle ajustado funciona a ~1,7 GB/s. La misma operación en un teléfono Android de gama media (Pixel 7) funciona a ~600 MB/s. Un portátil Intel de 2015 con AES solo por software alcanza ~250 MB/s.

Lo que esto indica: Web Crypto AES-GCM no es el cuello de botella para la mayoría de los flujos de transferencia de archivos cifrados. El cuello de botella suele ser la lectura del archivo, el marshaling JavaScript entre ArrayBuffers, o la subida por red. Optimiza esos primero.

Benchmarks para ejecutar dentro de la aplicación:

const blob = new Uint8Array(1024 * 1024 * 100); // 100 MB
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 }, true, ["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const start = performance.now();
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, blob);
console.log(`${(100 / (performance.now() - start) * 1000).toFixed(0)} MB/s`);

Ejecútalo en Chrome, Firefox y Safari. Chrome en Apple Silicon será el más rápido; Firefox algo más lento; Safari en Intel ligeramente por detrás. Los móviles funcionan al 30-50% de la velocidad de escritorio.

Usa Web Crypto, no bibliotecas JavaScript

Para AES-GCM, AES-CBC, PBKDF2, HMAC, RSA, ECDH, ECDSA y SHA-256/384/512, la Web Crypto API nativa del navegador usa aceleración hardware donde está disponible (AES-NI en x86, extensiones de cripto ARMv8 en móvil). Las bibliotecas JavaScript como @noble/ciphers o crypto-js en JS puro no pueden acceder a estas instrucciones y se ejecutan puramente en el intérprete.

Ratio de velocidad típico para AES-256-GCM en entradas de 1 MB:

  • Web Crypto (aceleración hardware): 1-2 GB/s
  • libsodium.js WASM: 400-800 MB/s
  • @noble/ciphers JS puro: 100-200 MB/s
  • crypto-js JS puro: 30-80 MB/s

Para un archivo de 5 GB, la diferencia entre Web Crypto y JS puro es ~3 segundos frente a ~50 segundos. Visible para el usuario. Prefiere siempre Web Crypto para lo que admite. Usa bibliotecas WASM (libsodium.js, argon2-browser) solo para algoritmos que Web Crypto no tiene (ChaCha20-Poly1305, Argon2id, X25519 en navegadores antiguos).

Procesamiento por fragmentos para archivos grandes

Los archivos de más de ~500 MB no caben cómodamente en un solo ArrayBuffer en la mayoría de dispositivos. Divídelos en piezas de 1-4 MB y cifra cada una.

const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB

async function encryptLargeFile(file, key) {
  const chunks = [];
  let chunkIndex = 0;
  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    const iv = new Uint8Array(12);
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, chunk
    );
    chunks.push({ iv, ct: new Uint8Array(ct) });
  }
  return chunks;
}

¿Por qué fragmentos de 4 MB? Los fragmentos más pequeños (por ejemplo, 64 KB) incurren en sobrecarga por llamada de la Web Crypto API que domina para entradas pequeñas. Los fragmentos más grandes (por ejemplo, 64 MB) no encajan bien en la caché L2/L3 y tienen peor presión de memoria. 1-4 MB tiende a ser el punto óptimo tanto en escritorio como en móvil.

Web Workers para mantener la interfaz responsiva

El cifrado en el hilo principal bloquea el renderizado y la entrada. Para cualquier trabajo de más de ~500 ms, deléga a un Web Worker.

// worker.js
self.onmessage = async (e) => {
  const { chunk, key, iv } = e.data;
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, chunk
  );
  self.postMessage(ciphertext, [ciphertext]);
};

// hilo principal
const worker = new Worker('/worker.js');
worker.postMessage({ chunk, key, iv }, [chunk]);
worker.onmessage = (e) => { /* gestiona el texto cifrado */ };

Transfiere ArrayBuffers con el segundo argumento de postMessage — esto transfiere la propiedad (copia cero) en lugar de clonar. Sin transferencia, clonar un fragmento de 4 MB añade ~20 ms de sobrecarga por fragmento.

La Web Crypto API está disponible en Workers, por lo que el cifrado real se ejecuta allí con rendimiento idéntico al hilo principal.

Cifrado paralelo de fragmentos

El cifrado AES-GCM por fragmento es independiente siempre que los nonces no colisionen. Deriva los nonces de forma determinista a partir del índice del fragmento y podrás cifrar fragmentos en paralelo entre múltiples Workers. Los rendimientos decrecientes aparecen después de ~4 workers en hardware típico porque Web Crypto es tan rápido que el cuello de botella se desplaza a la lectura del archivo y el paso de mensajes entre hilos. Haz benchmarks antes de comprometerte con la complejidad; a veces el cifrado secuencial con un solo worker es tan rápido como el paralelo con múltiples workers debido a la sobrecarga.

Streaming con Readable Streams

Para archivos verdaderamente grandes (20+ GB), evita cargar incluso fragmentos en memoria de golpe. Usa File.stream():

const reader = file.stream().getReader();
const writer = uploadStream.getWriter();
let chunkIndex = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, value
  );
  await writer.write(new Uint8Array(ct));
}
await writer.close();

Esto mantiene el uso de memoria acotado a un fragmento a la vez. El navegador lee del disco, cifra, escribe en el stream de subida, lee el siguiente fragmento. El uso de memoria permanece por debajo de 10 MB independientemente del tamaño del archivo.

Concurrencia de subida

El cifrado se ejecuta en paralelo con la subida, no de forma serial. Mientras el fragmento N se sube, el fragmento N+1 se está cifrando. Usa una canalización (pipeline):

const encryptQueue = [];
const uploadQueue = [];
const MAX_IN_FLIGHT = 4;

// El cifrador produce, el subidor consume
async function pipeline() {
  // Inicia el cifrado de los primeros fragmentos
  for (let i = 0; i < MAX_IN_FLIGHT; i++) startEncrypt(i);
  // A medida que cada uno completa, inicia la subida y encola el siguiente cifrado
  // (la cola acotada mantiene el uso de memoria predecible)
}

El slow-start de TCP y la sobrecarga del handshake TLS significan que las subidas iniciales son lentas. Mantener 4-8 subidas concurrentes a un único origen mantiene la tubería llena sin superar los límites del navegador (6 conexiones por origen en Chrome/Firefox).

WebAssembly para primitivos ausentes

Para la derivación de claves Argon2id o ChaCha20-Poly1305, Web Crypto no tiene soporte nativo. Las bibliotecas WASM llenan el vacío:

  • libsodium.js proporciona Argon2id, XChaCha20-Poly1305, crypto_secretstream
  • argon2-browser proporciona solo Argon2 pero con un bundle más pequeño
  • @noble/hashes proporciona Argon2 en JS puro (más lento) con un bundle diminuto

Las versiones WASM alcanzan el 60-80% de la velocidad nativa para la mayoría de cargas de trabajo cripto. Para transferencia de archivos, una derivación Argon2id de 1-2 segundos en la subida y la descarga es aceptable; una derivación de 5 segundos en JS puro no lo es.

Carga WASM dinámicamente para que no bloquee el renderizado inicial de la página:

const sodium = await import('libsodium-wrappers');
await sodium.ready;

Informes de progreso

Los cifrados grandes necesitan retroalimentación de progreso o los usuarios asumen que la aplicación se ha congelado. Cuenta los bytes cifrados y publica eventos de progreso:

let processed = 0;
for await (const chunk of chunks) {
  await encryptChunk(chunk);
  processed += chunk.size;
  onProgress({ done: processed, total: file.size, pct: processed / file.size });
}

Limita las actualizaciones de UI a ~10 Hz mediante requestAnimationFrame o una comprobación sencilla de timestamp; actualizaciones más frecuentes desperdician ciclos en repintados que los humanos no pueden ver.

Techo de memoria y presión de GC

Cada ArrayBuffer vive hasta que no se referencia más. Mantener 20 fragmentos cifrados de 4 MB supone ~80 MB de memoria fijada. En navegadores móviles con límites de memoria ajustados (iOS Safari limita a ~200-400 MB por pestaña), esto importa.

Libera referencias prontamente:

for (let i = 0; i < chunks.length; i++) {
  const chunk = chunks[i];
  chunks[i] = null; // deja que el GC recupere memoria
  const ct = await encrypt(chunk);
  await upload(ct);
}

Evita mantener un array completo de fragmentos cifrados en memoria; envíalos al subidor en streaming y suelta las referencias a medida que avanzas.

Objetivos en el mundo real

Para una transferencia de archivos cifrados de 1 GB en una pestaña del navegador: tiempo de cifrado 1-3 segundos (acelerado por hardware), tiempo de subida 30 segundos en una conexión de 300 Mbps, total de alrededor de 35 segundos de reloj de pared (limitado principalmente por la red), memoria bajo 50 MB máximo con streaming adecuado, interfaz responsiva en todo momento (el hilo principal nunca bloqueado más de 50 ms). Si no se cumplen esos objetivos, los usuarios lo notarán. El límite de 10 GB de HexaTransfer es alcanzable en el navegador porque Web Crypto más el streaming por fragmentos mantiene todo el camino eficiente.

Pruébalo en hexatransfer.com — gratis, sin cuenta, hasta 10 GB.

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