Ir para o conteúdo
HexaTransfer
Voltar ao blog
Criptografia e seguranca

Encriptação progressiva de ficheiros grandes: stream e encriptação

Encripte ficheiros grandes progressivamente usando APIs de streaming. Processe ficheiros multi-GB sem ficar sem memória, encriptando blocos à medida que são lidos.

A encriptação progressiva (em streaming) processa um ficheiro bloco a bloco sem nunca carregar o payload completo em memória. Para um upload de 10 GB num navegador, esta é a diferença entre uma aplicação que funciona e uma que crasha. O padrão: ler um bloco via File.stream(), encriptá-lo com AES-256-GCM usando um nonce único, enviar o texto cifrado directamente para um stream de upload via fetch com corpo ReadableStream, libertar o buffer e avançar. A memória fica limitada a 4-16 MB independentemente do tamanho do ficheiro. O crypto_secretstream_xchacha20poly1305 da libsodium adiciona semântica de AEAD em streaming adequada, incluindo detecção de truncagem. Eis a implementação concreta, com números que se verificam em hardware real.

Por que a encriptação com buffer completo falha

Um FileReader.readAsArrayBuffer(file) num ficheiro de 10 GB aloca 10 GB de memória no navegador. No Chrome para desktop com 32 GB de RAM, pode funcionar. No Safari mobile com limite de 400 MB por tab, crasha antes de terminar. No Firefox, um ArrayBuffer acima de 2 GB atinge os limites internos do V8 e lança RangeError.

Mesmo em hardware capaz de lidar com a alocação, manter 10 GB bloqueia o garbage collection e desencadeia paginação patológica. A resposta correcta é nunca alocar o buffer completo.

O padrão 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));
}

Duas APIs fundamentais: File.stream() fornece um ReadableStream do conteúdo do ficheiro; fetch com um corpo ReadableStream faz o upload em streaming sem buffer do corpo completo. duplex: "half" é necessário no Chrome 105+ para corpos de pedido em streaming.

Uso de memória: em qualquer momento, um bloco de origem, um restante em buffer e um bloco encriptado. Pico de ~12-16 MB para um tamanho de bloco de 4 MB.

Gestão de nonces em streams

Cada bloco precisa de um nonce único. Três abordagens:

Baseado em contador: incorpora o índice do bloco no nonce de 96 bits. Define os 32 bits superiores como um prefixo aleatório (para evitar colisões entre ficheiros usando a mesma chave), os 64 bits inferiores como o índice do bloco.

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;
}

Aleatório por bloco: crypto.getRandomValues(new Uint8Array(12)). Seguro para chaves por ficheiro; as colisões por aniversário entre blocos surgem a ~2^48. Armazene o nonce junto ao texto cifrado de cada bloco.

Derivado via HKDF: use HKDF para derivar chaves por bloco e depois use um nonce fixo. Desnecessariamente complexo para a maioria dos casos.

Para uma chave nova por ficheiro, o baseado em contador é o mais simples e evita a necessidade de armazenar um nonce separado por bloco.

Ataques de truncagem e como detectá-los

Uma lacuna crítica no AES-GCM em blocos ingénuo: um atacante pode eliminar blocos finais e cada bloco sobrevivente desencripta correctamente. A detecção requer vincular os blocos entre si.

Opção 1: incluir a contagem total de blocos no AAD de cada bloco. O receptor verifica que a contagem corresponde ao que foi recebido.

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
);

Opção 2: usar o crypto_secretstream_xchacha20poly1305 da libsodium. Encadeia blocos criptograficamente e emite um marcador TAG_FINAL que o receptor verifica:

const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// Para cada bloco, push com TAG_MESSAGE
// Para o último bloco, push com TAG_FINAL
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
  state, lastChunk, null,
  sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);

Na desencriptação, as chamadas pull do receptor verificam a cadeia e detectam blocos finais em falta. Esta é a opção mais limpa quando está disposto a incluir libsodium.js.

Desencriptação em streaming no receptor

Padrão simétrico no lado do 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; // mais tag 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));
    }
  }
  // Tratar o bloco final parcial
  if (buffer.length > 0) {
    const iv = makeNonce(chunkIndex);
    const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
    onChunk(new Uint8Array(pt));
  }
}

No receptor, os callbacks onChunk podem enviar bytes desencriptados para a File System Access API para escritas directas em disco, ou concatenar num Blob para download nativo do navegador.

Escrita em disco via File System Access API

Para downloads muito grandes, carregar o resultado desencriptado completo num Blob derrota o propósito do streaming. A File System Access API (Chrome 86+, Safari parcial via OPFS) permite ao receptor escolher um ficheiro local e escrever blocos directamente:

const handle = await window.showSaveFilePicker({
  suggestedName: "ficheiro-desencriptado",
});
const writable = await handle.createWritable();

await streamDecrypt(url, key, async (chunk) => {
  await writable.write(chunk);
});
await writable.close();

A memória permanece limitada porque os blocos vão para o disco imediatamente. A UI mostra progresso realista. Os utilizadores podem cancelar a meio do download.

O Firefox ainda não suporta showSaveFilePicker no desktop. Recaia para construir um Blob em memória (adequado para ficheiros com menos de algumas centenas de MB) ou o Origin Private File System para fluxos Firefox multi-GB.

Upload em streaming via fetch

O Chrome 105+ e o Firefox 127+ suportam corpos de pedido em streaming com duplex: "half". Antes disso, os uploads tinham de ser buffers completos ou multipart com codificação de transferência em blocos tratada manualmente.

Para uploads multipart compatíveis com S3, cada parte é enviada como um pedido separado. Divida o stream encriptado em partes de 5-25 MB (o tamanho mínimo de parte S3 é 5 MB, o máximo é 5 GB) e termine com a chamada final CompleteMultipartUpload. Funciona em todos os navegadores e dá-lhe retomabilidade de graça.

Reporte de progresso em streams

Monitorize os bytes processados:

let processed = 0;
const onChunk = (chunkSize) => {
  processed += chunkSize;
  updateProgressBar(processed / file.size);
};

Limite as actualizações de progresso a 10-20 Hz com requestAnimationFrame para evitar repintagens desperdiçadas. Em ficheiros de 10 GB a 100 MB/s de velocidade de processamento, isso ainda são 100 eventos por segundo em bruto, muito mais do que a UI precisa.

Benchmarks num ficheiro de 10 GB

Num MacBook Pro 2024 (M3 Max) com SSD rápido: leitura bruta do disco via File.stream() a 2,5 GB/s, AES-256-GCM via Web Crypto a 1,7 GB/s, o pipeline combinado a 1,1 GB/s (limitado pela cadeia serial), upload via Gigabit Ethernet a 115 MB/s (limitado pela rede), pico de memória de 14 MB independentemente do tamanho do ficheiro. Os números em dispositivos móveis são aproximadamente 30-50% dos do desktop. Um ficheiro de 10 GB demora ~90 segundos num Gigabit, ~15 minutos numa ligação doméstica típica. A encriptação não é o gargalo — a rede é.

Recuperação de erros

As interrupções de rede durante um upload de 10 GB são comuns. Estratégias:

  • Uploads retomáveis via multipart: cada parte é independente; reenvie apenas a parte que falhou.
  • Protocolo tus: padrão aberto de upload retomável suportado por empresas como a Vimeo; nativo ao streaming.
  • Manter o handle do ficheiro de origem aberto: se File.slice for repetível, recomece a partir do último bloco bem-sucedido.

O limite de 10 GB do HexaTransfer é atingível num único tab de navegador porque este pipeline de streaming mantém a memória limitada e trata graciosamente a interrupção com reenvio multipart. O mesmo padrão escala para limites maiores se o seu backend o suportar.

A versão curta

Não aloque o ficheiro completo. Leia em blocos, encripte em blocos, envie em blocos, liberte cada bloco à medida que avança. Vincule blocos criptograficamente com AAD ou AEAD em streaming para derrotar a truncagem. Barra de progresso em tudo. Teste em dispositivos móveis, não apenas em desktop.

Experimente em hexatransfer.com — gratuito, sem conta, até 10 GB.

Envie arquivos grandes com segurança e criptografia de ponta a ponta

Transfira arquivos de até 10 GB gratuitamente com criptografia de ponta a ponta. Sem necessidade de conta. Seus arquivos são criptografados no navegador antes do envio — ninguém mais pode lê-los.

Enviar um arquivo