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.

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 (7 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é 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