Cifrado progresivo de archivos grandes: flujo y cifrado
Cifra archivos grandes progresivamente usando APIs de streaming. Procesa archivos de varios GB sin quedarte sin memoria cifrando por fragmentos.
El cifrado progresivo (en streaming) procesa un archivo fragmento a fragmento sin cargar nunca el payload completo en memoria. Para una subida de 10 GB en un navegador, esto marca la diferencia entre una aplicación que funciona y una que se bloquea. El patrón: lee un fragmento mediante File.stream(), cífralo con AES-256-GCM usando un nonce único, canaliza el texto cifrado directamente a un stream de subida mediante fetch con un cuerpo ReadableStream, libera el buffer y continúa. La memoria se mantiene acotada entre 4-16 MB independientemente del tamaño del archivo. crypto_secretstream_xchacha20poly1305 de libsodium añade semántica AEAD de streaming correcta, incluida la detección de truncamiento. Aquí tienes la implementación concreta, con cifras que se sostienen en hardware real.
Por qué el cifrado en buffer falla
Un FileReader.readAsArrayBuffer(file) sobre un archivo de 10 GB asigna 10 GB en memoria del navegador. En Chrome de escritorio con 32 GB de RAM, puede funcionar. En Safari móvil con un límite de memoria de 400 MB por pestaña, se bloquea antes de terminar. En Firefox, un ArrayBuffer de más de 2 GB alcanza los límites internos y lanza RangeError.
Incluso en hardware que puede gestionar la asignación, mantener 10 GB bloquea la recolección de basura y provoca una paginación patológica. La respuesta correcta es no asignar nunca el buffer completo.
El patrón de streaming
async function streamEncrypt(file, key, uploadURL) {
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
const reader = file.stream().getReader();
let chunkIndex = 0;
let buffer = new Uint8Array(0);
const uploadStream = new ReadableStream({
async pull(controller) {
while (buffer.length < CHUNK_SIZE) {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) {
await enqueueEncrypted(controller, buffer, chunkIndex++, key);
}
controller.close();
return;
}
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer, 0);
newBuf.set(value, buffer.length);
buffer = newBuf;
}
const chunk = buffer.subarray(0, CHUNK_SIZE);
buffer = buffer.subarray(CHUNK_SIZE);
await enqueueEncrypted(controller, chunk, chunkIndex++, key);
}
});
await fetch(uploadURL, {
method: "POST",
body: uploadStream,
duplex: "half",
headers: { "Content-Type": "application/octet-stream" },
});
}
async function enqueueEncrypted(controller, chunk, index, key) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, BigInt(index));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
controller.enqueue(new Uint8Array(ct));
}
Dos APIs clave: File.stream() proporciona un ReadableStream del contenido del archivo; fetch con un cuerpo ReadableStream transmite la subida sin almacenar en buffer el cuerpo completo. duplex: "half" es obligatorio en Chrome 105+ para cuerpos de solicitud en streaming.
Uso de memoria: en cualquier momento, un fragmento fuente, un resto en buffer y un fragmento cifrado. Pico de ~12-16 MB para un tamaño de fragmento de 4 MB.
Gestión de nonces en streams
Cada fragmento necesita un nonce único. Tres enfoques:
Basado en contador: incrusta el índice del fragmento en el nonce de 96 bits. Establece los 32 bits superiores con un prefijo aleatorio (para evitar colisiones entre archivos que usan la misma clave), los 64 bits inferiores con el índice del fragmento.
const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
return iv;
}
Aleatorio por fragmento: crypto.getRandomValues(new Uint8Array(12)). Seguro para claves por archivo; las colisiones de cumpleaños entre fragmentos aparecen alrededor de 2^48. Almacena el nonce junto al texto cifrado de cada fragmento.
Derivado mediante HKDF: usa HKDF para derivar claves por fragmento, luego usa un nonce fijo. Excesivo para la mayoría de los casos.
Para una clave nueva por archivo, el basado en contador es el más sencillo y evita la necesidad de almacenar un nonce separado por fragmento.
Ataques de truncamiento y cómo detectarlos
Un punto crítico en AES-GCM por fragmentos naive: un atacante puede eliminar los fragmentos finales y cada fragmento superviviente se descifra correctamente. La detección requiere vincular los fragmentos entre sí.
Opción 1: incluye el recuento total de fragmentos en el AAD de cada fragmento. El receptor verifica que el recuento coincide con lo recibido.
const aad = new TextEncoder().encode(JSON.stringify({
totalChunks,
fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
key,
chunk
);
Opción 2: usa crypto_secretstream_xchacha20poly1305 de libsodium. Encadena los fragmentos criptográficamente y emite un marcador TAG_FINAL que el receptor verifica:
const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// Para cada fragmento, push con TAG_MESSAGE
// Para el último fragmento, push con TAG_FINAL
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
state, lastChunk, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);
En el descifrado, las llamadas pull del receptor verifican la cadena y detectan los fragmentos finales que faltan. Esta es la opción más limpia cuando estás dispuesto a incluir libsodium.js.
Descifrado en streaming en el lado del receptor
Patrón simétrico en el lado del receptor:
async function streamDecrypt(downloadURL, key, onChunk) {
const response = await fetch(downloadURL);
const reader = response.body.getReader();
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // más la etiqueta GCM
while (true) {
const { done, value } = await reader.read();
if (done) break;
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer);
newBuf.set(value, buffer.length);
buffer = newBuf;
while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
const iv = makeNonce(chunkIndex++);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
onChunk(new Uint8Array(pt));
}
}
// Gestiona el fragmento parcial final
if (buffer.length > 0) {
const iv = makeNonce(chunkIndex);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
onChunk(new Uint8Array(pt));
}
}
En el receptor, los callbacks onChunk pueden canalizar los bytes descifrados a la File System Access API para escrituras directas en disco, o concatenarlos en un Blob para la descarga nativa del navegador.
Escritura en disco mediante la File System Access API
Para descargas muy grandes, cargar el resultado descifrado completo en un Blob anula el propósito del streaming. La File System Access API (Chrome 86+, parcial en Safari mediante OPFS) permite al receptor elegir un archivo local y escribir fragmentos directamente:
const handle = await window.showSaveFilePicker({
suggestedName: "archivo-descifrado",
});
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => {
await writable.write(chunk);
});
await writable.close();
La memoria se mantiene acotada porque los fragmentos van al disco de inmediato. La interfaz muestra un progreso realista. Los usuarios pueden cancelar a mitad de la descarga.
Firefox aún no admite showSaveFilePicker en escritorio. Recurre a construir un Blob en memoria (correcto para archivos de menos de unos cientos de MB) o el Origin Private File System para flujos de trabajo de Firefox con varios GB.
Streaming de subida mediante fetch
Chrome 105+ y Firefox 127+ admiten cuerpos de solicitud en streaming con duplex: "half". Antes de eso, las subidas tenían que ser o buffers completos o multipart con codificación de transferencia chunked gestionada manualmente.
Para subidas multipart compatibles con S3, cada parte se sube como una solicitud separada. Divide el stream cifrado en partes de 5-25 MB (el tamaño mínimo de parte de S3 es 5 MB, el máximo es 5 GB) y completa con la llamada final CompleteMultipartUpload. Esto funciona en todos los navegadores y te ofrece reanudabilidad de forma gratuita.
Informes de progreso sobre streams
Rastrea los bytes procesados:
let processed = 0;
const onChunk = (chunkSize) => {
processed += chunkSize;
updateProgressBar(processed / file.size);
};
Limita las actualizaciones de progreso a 10-20 Hz con requestAnimationFrame para evitar repintados innecesarios. En archivos de 10 GB a una velocidad de procesamiento de 100 MB/s, eso supone 100 actualizaciones por segundo de eventos en bruto, muchos más de los que la interfaz necesita.
Benchmarks para un archivo de 10 GB
En un MacBook Pro de 2024 (M3 Max) con un SSD rápido: lectura en bruto desde disco mediante File.stream() a 2,5 GB/s, AES-256-GCM mediante Web Crypto a 1,7 GB/s, el pipeline combinado a 1,1 GB/s (acotado por la cadena serial), subida por Ethernet Gigabit a 115 MB/s (limitado por la red), pico de memoria de 14 MB independientemente del tamaño del archivo. Los números en móvil son aproximadamente el 30-50% de los de escritorio. Un archivo de 10 GB se sube en ~90 segundos en Gigabit, ~15 minutos en una conexión doméstica típica. El cifrado no es el cuello de botella — la red sí lo es.
Recuperación de errores
Las interrupciones de red durante una subida de 10 GB son habituales. Estrategias:
- Subidas reanudables mediante multipart: cada parte es independiente; solo vuelve a subir la parte que falló.
- Protocolo tus: estándar de subida reanudable de código abierto compatible con empresas como Vimeo; nativo para streaming.
- Mantener abierto el identificador del archivo fuente: si
File.slicees repetible, reinicia desde el último fragmento exitoso.
El límite de 10 GB de HexaTransfer es alcanzable en una sola pestaña del navegador porque este pipeline de streaming mantiene la memoria acotada y gestiona con gracia las interrupciones mediante reintento multipart. El mismo patrón escala a límites mayores si tu backend lo admite.
La versión corta
No asignes el archivo completo. Lee por fragmentos, cifra por fragmentos, sube por fragmentos, libera cada fragmento a medida que avanzas. Vincula los fragmentos criptográficamente con AAD o AEAD en streaming para derrotar el truncamiento. Añade barra de progreso a todo. Prueba en móvil, no solo en escritorio.
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