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

Tutorial de encriptação do lado do cliente: construa de raiz

Tutorial passo a passo para implementar encriptação do lado do cliente numa aplicação web. Encripte ficheiros no browser antes de saírem do dispositivo do utilizador.

Atualizado em

Publicado por HexaTransfer, serviço operado por Gaprod — Groupe Dwenola SAS. Estes guias apresentam nosso serviço e outros produtos. Sobre o editor

A encriptação de ficheiros do lado do cliente no browser exige cerca de 80 linhas de JavaScript usando a Web Crypto API. O padrão: gerar uma chave AES-256-GCM no browser, encriptar o ficheiro com um nonce aleatório de 96 bits, carregar o texto cifrado via HTTPS/TLS 1.3, e partilhar o URL resultante com a chave incorporada no identificador de fragmento (#chave=...) que os browsers nunca transmitem aos servidores. O destinatário decifra no browser usando esse mesmo fragmento. Este tutorial percorre uma implementação funcional, incluindo fragmentação para ficheiros grandes, chaves derivadas de palavras-passe via PBKDF2 com 600 000 iterações, e os erros que afetam as primeiras tentativas.

A arquitetura num só diagrama

[Browser do Remetente]                  [Servidor]                    [Browser do Destinatário]
  Ler Ficheiro → Chave AES (aleatória)   Aceita POST                   GET texto cifrado
  Encriptar com AES-256-GCM             Armazena blob cifrado          Extrair chave do fragmento #
  POST texto cifrado                    Sem chave, sem texto simples   Decifrar no browser
  Construir URL com #chave=...          Devolve URL de transferência   Guardar Ficheiro no disco

O servidor é um armazenamento de blobs simples. Vê apenas texto cifrado e não consegue decifrar. A chave de decifração reside no fragmento do URL, que os browsers tratam de forma especial: nunca é enviado na linha de pedido HTTP. Este é o fundamento de cada serviço de transferência zero-knowledge incluindo o HexaTransfer.

Passo 1: Gerar uma chave simétrica

async function generateKey() {
  return await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // extraível para exportar para o URL
    ["encrypt", "decrypt"]
  );
}

O sinalizador extractable: true é obrigatório porque precisamos de serializar a chave num fragmento de URL. Se estiver a construir um fluxo onde a chave reside apenas em memória (por exemplo, uma ferramenta de colar e enviar), defina-o como false.

Passo 2: Ler o ficheiro como ArrayBuffer

async function readFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsArrayBuffer(file);
  });
}

Isto carrega o ficheiro inteiro na memória. Adequado para ficheiros abaixo de 500 MB. Para ficheiros maiores, avance para a secção de streaming.

Passo 3: Encriptar o buffer

async function encryptFile(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    plaintext
  );
  // Antepor IV ao texto cifrado para o destinatário o poder extrair
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv, 0);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return combined.buffer;
}

O nonce (IV) tem 96 bits (12 bytes), conforme NIST SP 800-38D. Não é secreto mas tem de ser único por chave. Os nonces aleatórios são seguros aqui porque geramos uma chave nova por ficheiro. Antepor o IV ao texto cifrado é uma convenção comum; o destinatário separa-o antes de decifrar.

Passo 4: Carregar o texto cifrado

async function uploadCiphertext(ciphertext) {
  const response = await fetch("/api/upload", {
    method: "POST",
    body: ciphertext,
    headers: { "Content-Type": "application/octet-stream" },
  });
  const { fileId } = await response.json();
  return fileId;
}

O servidor recebe um blob binário, atribui-lhe um ID, armazena-o e devolve esse ID. Nenhum cabeçalho revela o nome do ficheiro, nenhum parâmetro de consulta transporta a chave. Se o disco do servidor for roubado amanhã, um atacante vê apenas texto ininteligível.

Passo 5: Construir o URL de partilha com a chave no fragmento

async function buildShareURL(fileId, key) {
  const rawKey = await crypto.subtle.exportKey("raw", key);
  const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${location.origin}/f/${fileId}#${keyBase64}`;
}

A codificação base64url (com - e _ em vez de + e /) evita problemas de codificação de URL. O preenchimento = é removido para estética.

O fragmento (#...) é o elemento especial aqui. Quando o destinatário carrega o URL, o browser mantém o fragmento do lado do cliente. O GET HTTP para /f/{fileId} não inclui #keyBase64 na linha de pedido, pelo que o servidor nunca aprende a chave. Verifique isto abrindo as ferramentas de desenvolvimento do browser em qualquer URL com fragmento e observando o separador Rede.

Passo 6: Decifração do lado do destinatário

async function downloadAndDecrypt() {
  const fileId = location.pathname.split("/").pop();
  const keyBase64 = location.hash.slice(1);
  const rawKey = Uint8Array.from(
    atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
    c => c.charCodeAt(0)
  );
  const key = await crypto.subtle.importKey(
    "raw", rawKey, "AES-GCM", false, ["decrypt"]
  );
  const response = await fetch(`/api/download/${fileId}`);
  const combined = new Uint8Array(await response.arrayBuffer());
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, ciphertext
  );
  const blob = new Blob([plaintext]);
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "ficheiro-transferido";
  a.click();
}

A etiqueta de autenticação do GCM é verificada durante decrypt(). Se o texto cifrado foi adulterado, a chamada lança OperationError, um modo de falha limpo.

Chaves derivadas de palavras-passe via PBKDF2

Se os utilizadores fornecerem uma palavra-passe em vez de uma chave aleatória, derive a chave AES via PBKDF2:

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

600 000 iterações de PBKDF2-SHA-256 é a base de referência OWASP de 2023. O salt tem de ter 16 bytes aleatórios e ser armazenado junto ao texto cifrado (não é secreto, apenas tem de ser único). Para código novo, considere Argon2id via uma biblioteca como argon2-browser — resiste muito melhor a ataques GPU do que o PBKDF2.

Streaming para ficheiros grandes

Os ficheiros acima de 500 MB devem ser fragmentados. Ler via File.stream(), encriptar cada fragmento, carregar sequencialmente:

async function encryptStream(file, key) {
  const reader = file.stream().getReader();
  const chunks = [];
  let chunkIndex = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const iv = new Uint8Array(12);
    // Codificar o índice do fragmento no nonce para garantir unicidade
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, value
    );
    chunks.push({ iv, ct });
  }
  return chunks;
}

Derivar o nonce do índice do fragmento garante unicidade sem rastrear o estado. A remontagem do lado do destinatário decifra os fragmentos por ordem e concatena-os.

Para AEAD verdadeiramente em streaming, o crypto_secretstream_xchacha20poly1305 do libsodium via libsodium.js é mais limpo e deteta ataques de truncamento. A Web Crypto não tem um primitivo equivalente em 2026.

Testes e erros a evitar

Erros comuns a evitar:

  • Usar Math.random() para chaves ou nonces: use sempre crypto.getRandomValues().
  • Reutilizar um nonce com a mesma chave: quebra a segurança do GCM. As chaves aleatórias por ficheiro tornam isto seguro; os fluxos por fragmento precisam de nonces únicos por fragmento.
  • Não verificar HTTPS: crypto.subtle é undefined em origens inseguras. Teste em localhost ou com um certificado auto-assinado durante o desenvolvimento.
  • Armazenar chaves em localStorage: qualquer XSS na sua origem consegue lê-las. Use o padrão de fragmento de URL em vez disso, ou chaves não extraíveis.
  • Esquecer de incluir o IV com o texto cifrado: a decifração falha sem erro útil. Antepõe ou serializa sempre junto ao texto cifrado.
  • Tratar mal o fragmento: não publique acidentalmente o URL (com fragmento) num serviço de terceiros. Partilhe apenas através de canais end-to-end se o fragmento for sensível.

Responsabilidades do lado do servidor

O trabalho do servidor numa arquitetura de encriptação do lado do cliente é pequeno: aceitar POST, armazenar blob, devolver ID, servir GET do blob, eliminar na expiração. Sem criptografia. O que o servidor deve fazer além do armazenamento:

  • Aplicar limites de tamanho de ficheiro (evitar abusos)
  • Limitar a taxa de carregamentos e transferências
  • Definir retenção curta (14 dias é uma predefinição razoável, como o HexaTransfer)
  • Registar apenas o necessário (hora de carregamento, sem IPs se privacy-first)
  • Servir via TLS 1.3 com HSTS
  • Cabeçalhos CORS a restringir origens se a API for chamada apenas a partir dos seus domínios

Juntar tudo

Uma aplicação mínima funcional cabe num único ficheiro HTML mais um backend Express de 50 linhas. Total de dependências: nenhuma no cliente (Web Crypto é nativa), Express mais multer no servidor. A encriptação é tão robusta quanto o primitivo AES-256-GCM porque é literalmente isso que está a usar. Não há algoritmo secreto que possa correr mal, apenas os primitivos a usar corretamente.

As partes mais difíceis são os casos extremos: ficheiros grandes, fluxos de palavra-passe para chave, UX do destinatário quando a decifração falha, gerir graciosamente ligações expiradas. A criptografia principal é direta.

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

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

Envie até 50 GiB grátis com criptografia de ponta a ponta, sem conta nem e-mail. Escolha de 1 a 30 dias; 14 por padrão.

Enviar um arquivo