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 semprecrypto.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