O ECDH (Elliptic Curve Diffie-Hellman) é a base criptográfica do TLS 1.3, do Signal Protocol e de praticamente toda a troca de chaves segura moderna. Em 2026, com 70,1% dos sites a usar TLS 1.3 segundo a Qualys SSL Labs, compreender ECDH deixou de ser opcional para qualquer programador que trabalhe com segurança. Este tutorial mostra como implementar ECDH em Node.js, do zero ao projeto completo, em 11 passos concretos.
O que é ECDH e Porquê Importa em 2026
O ECDH é um protocolo de troca de chaves que permite a duas partes, sem comunicação prévia, chegar ao mesmo segredo partilhado através de uma rede insegura. Baseia-se no problema do logaritmo discreto em curvas elípticas, um problema matematicamente muito mais difícil de resolver do que a fatorização de inteiros usada pelo RSA clássico.
A vantagem prática é enorme. Uma chave P-256 de 256 bits oferece segurança equivalente a uma chave RSA de 3072 bits, com operações criptográficas significativamente mais rápidas. Em benchmarks de produção publicados em 2025, a migração para TLS 1.3 com ECDHE reduziu a latência p95 de 318 ms para 194 ms, uma melhoria de 40%, e o consumo de CPU do balanceador de carga caiu 28%. A taxa de falhas no handshake caiu de 1,2% com TLS 1.2 para 0,4% com TLS 1.3.
No TLS 1.3, o RSA como mecanismo de troca de chaves foi completamente removido. O protocolo usa exclusivamente (EC)DHE, garantindo forward secrecy por defeito. Mesmo que um atacante grave todo o tráfego cifrado e depois obtenha a chave privada do servidor, não consegue desencriptar as sessões passadas. Cada sessão gera um par de chaves efémero que é destruído após a troca.
O Node.js expõe ECDH através do módulo node:crypto, com suporte nativo para curvas NIST (P-256, P-384, P-521) e curvas modernas de alta performance (X25519, X448). Não é necessária nenhuma dependência externa para o núcleo do protocolo. Para projetos onde a segurança da cadeia de fornecimento é crítica, eliminar pacotes npm é um ganho real: menos vetores de ataque, menos atualizações de segurança urgentes, menos risco de compromisso via supply chain.
O Signal Protocol, o WireGuard e o Noise Protocol Framework usam X25519 (uma variante de ECDH sobre Curve25519) como base de toda a criptografia de sessão. A adoção de 58% de 0-RTT em TLS 1.3 (dado de 2025) demonstra que as sessões de retorno beneficiam ainda mais, com latência zero no handshake para utilizadores recorrentes.
Pré-requisitos: Versões e Ferramentas Necessárias
Antes de começar, verifica se tens o ambiente correto. A tabela seguinte lista o que é obrigatório e as versões mínimas recomendadas para 2026.
| Componente | Versão Mínima | Versão Recomendada | Motivo |
|---|---|---|---|
| Node.js | 18.0.0 LTS | 22.x LTS | HKDF síncrono, KeyObject API |
| OpenSSL (incluído) | 3.0 | 3.3+ | X25519, X448, curvas modernas |
| npm | 9.x | 10.x | lockfile v3, audit melhorado |
| Sistema Operativo | Linux / macOS / Windows | Linux (Ubuntu 22.04+) | Melhor suporte OpenSSL nativo |
| Editor | Qualquer | VS Code com ESLint | Autocompletion para API crypto |
Verifica a versão do Node.js com node --version. Para confirmar as curvas disponíveis no teu build de OpenSSL, executa o seguinte comando:
node -e "
const c = require('crypto');
const nist = c.getCurves().filter(x => ['prime256v1','secp384r1','secp521r1'].includes(x));
const modernas = c.getCurves().filter(x => ['X25519','X448'].includes(x));
console.log('Curvas NIST disponíveis:', nist);
console.log('Curvas modernas disponíveis:', modernas);
console.log('Node.js:', process.version);
"
Se aparecerem prime256v1, secp384r1, secp521r1 e X25519, o ambiente está pronto. Caso o X25519 não apareça, atualiza o Node.js para a versão 20.x LTS ou superior. Não são necessários pacotes npm externos para este tutorial: o módulo node:crypto cobre tudo, desde a geração de chaves até à encriptação AES-256-GCM final.
Como Funciona a Criptografia de Curva Elíptica
Uma curva elíptica é definida pela equação y² = x³ + ax + b sobre um corpo finito. Os pontos que satisfazem esta equação formam um grupo matemático com uma operação de adição geométrica. A partir de um ponto base público G, é fácil calcular nG (multiplicar o ponto n vezes), mas, dado nG e G, descobrir n é computacionalmente inviável para curvas bem escolhidas. A este problema chama-se Problema do Logaritmo Discreto em Curvas Elípticas (ECDLP).
No ECDH, a Alice escolhe um número privado aleatório a e publica A = aG. O Bob escolhe b e publica B = bG. A Alice calcula aB = a(bG) = abG. O Bob calcula bA = b(aG) = abG. Ambos chegam ao mesmo ponto abG sem nunca terem transmitido os seus números privados. Um atacante que veja apenas A e B teria de resolver o ECDLP para descobrir abG.
A coordenada x do ponto resultante é usada como segredo partilhado. No entanto, este valor não deve ser usado diretamente como chave de encriptação. As coordenadas de pontos de curvas elípticas não são uniformemente distribuídas como sequências aleatórias. Por isso, o passo seguinte é sempre passar o segredo por uma função de derivação de chaves (KDF), tipicamente HKDF conforme o RFC 5869.
A diferença fundamental em relação ao Diffie-Hellman clássico (sobre grupos multiplicativos de inteiros) é o tamanho das chaves. Para atingir 128 bits de segurança, o DH clássico precisa de chaves de 3072 bits. O ECDH atinge o mesmo nível com apenas 256 bits. Esta diferença de 12x no tamanho da chave traduz-se em handshakes mais rápidos, menos dados transmitidos e menos CPU consumida, como mostram os benchmarks de 2025 citados acima.
Curvas Elípticas em Node.js: Guia de Seleção para 2026
O Node.js suporta múltiplas curvas com diferentes características de segurança, performance e compatibilidade. Escolher a curva errada pode comprometer a segurança ou criar problemas de interoperabilidade. A tabela seguinte resume as opções relevantes para 2026.
| Curva | Nome em Node.js | Segurança | Chave Pública | Melhor Para |
|---|---|---|---|---|
| P-256 (secp256r1) | prime256v1 | 128 bits | 33 bytes (comprimida) | TLS 1.3, compatibilidade máxima |
| P-384 (secp384r1) | secp384r1 | 192 bits | 49 bytes (comprimida) | Requisitos governamentais NSA Suite B |
| P-521 (secp521r1) | secp521r1 | 256 bits | 67 bytes (comprimida) | Dados ultra-sensíveis, longo prazo |
| X25519 | X25519 | 128 bits | 32 bytes | Performance, Signal/WireGuard |
| X448 | X448 | 224 bits | 56 bytes | Segurança extra, projetos novos |
Para a maioria dos projetos web em 2026, a escolha é entre P-256 e X25519. O P-256 oferece compatibilidade máxima com sistemas legados e hardware HSM. O X25519 foi desenhado explicitamente para evitar as vulnerabilidades de implementação das curvas NIST e é mais rápido em CPU sem hardware AES-NI. Em termos práticos: usa P-256 se precisas de certificados X.509 ou integração com HSM; usa X25519 se constróis um sistema novo sem requisitos de compatibilidade legada.
As curvas P-384 e P-521 são necessárias apenas em contextos com requisitos regulatórios específicos, como sistemas governamentais sujeitos à Suite B da NSA ou infraestruturas críticas com políticas que exigem níveis de segurança acima de 128 bits. Para a maioria das aplicações comerciais, P-256 ou X25519 são mais do que suficientes. A situação mudará com a migração para algoritmos pós-quânticos como o ML-KEM (Kyber), padronizado pelo NIST em 2024, mas o ECDH continuará relevante em sistemas híbridos durante a transição.
Passo 1: Configurar o Projeto Node.js
Cria uma pasta para o projeto e inicializa o ambiente. Este projeto não requer dependências externas, o que simplifica a auditoria de segurança e elimina riscos de supply chain.
# Criar e entrar na pasta do projeto
mkdir ecdh-nodejs && cd ecdh-nodejs
npm init -y
# Confirmar versão do Node.js (mínimo 18.x)
node --version
# Verificar curvas disponíveis no OpenSSL incluído
node -e "
const c = require('crypto');
console.log('Curvas NIST:', c.getCurves().filter(x => ['prime256v1','secp384r1','secp521r1'].includes(x)));
console.log('Curvas modernas:', c.getCurves().filter(x => ['X25519','X448'].includes(x)));
"
# Saída esperada:
# Curvas NIST: [ 'prime256v1', 'secp384r1', 'secp521r1' ]
# Curvas modernas: [ 'X25519', 'X448' ]
Adiciona "type": "module" ao package.json para usar ES Modules modernos. Em alternativa, podes usar CommonJS com require(); a API node:crypto funciona da mesma forma em ambos os sistemas de módulos.
Cria a seguinte estrutura de ficheiros para organizar o projeto:
mkdir -p src
# Estrutura do projeto
# ecdh-nodejs/
# ├── package.json
# └── src/
# ├── utils.js (funções ECDH reutilizáveis)
# ├── exchange.js (demonstração da troca de chaves)
# ├── crypto.js (encriptação AES-256-GCM)
# └── chat-seguro.js (projeto completo)
touch src/utils.js src/exchange.js src/crypto.js src/chat-seguro.js
Passo 2 e 3: Gerar Chaves ECDH e Trocar Chaves Públicas
O passo mais crítico do protocolo é a geração correta do par de chaves. Cada parte deve gerar um par independente e fresco para cada sessão (chaves efémeras). Reutilizar a mesma chave privada em múltiplas sessões elimina a propriedade de forward secrecy, um dos principais benefícios do ECDH sobre sistemas de chave pública estática.
// src/utils.js
import { createECDH, hkdfSync, randomBytes } from 'node:crypto';
// Gerar par de chaves ECDH efémero para P-256
export function gerarParDeChaves(curva = 'prime256v1') {
const ecdh = createECDH(curva);
ecdh.generateKeys();
return {
// Chave privada: NUNCA transmitir ou persistir sem proteção
chavePrivada: ecdh.getPrivateKey('hex'),
// Chave pública comprimida: 33 bytes para P-256 (prefixo 0x02 ou 0x03 + coord. x)
chavePublica: ecdh.getPublicKey('hex', 'compressed'),
instancia: ecdh,
};
}
// Exportar chave pública em base64 para transmissão em JSON/HTTP
export function exportarChavePublica(instancia) {
return instancia.getPublicKey('base64', 'compressed');
}
// Demonstração
const exemplo = gerarParDeChaves();
console.log('Chave pública (hex, comprimida):', exemplo.chavePublica);
console.log(
'Comprimento:',
Buffer.from(exemplo.chavePublica, 'hex').length,
'bytes' // 33 bytes para P-256
);
// Saída:
// Chave pública (hex, comprimida): 03a1b2c3...
// Comprimento: 33 bytes
O formato comprimido é preferível para transmissão em rede. Para P-256, a chave pública comprimida ocupa apenas 33 bytes em vez dos 65 bytes do formato não comprimido. O prefixo 0x02 ou 0x03 indica se a coordenada y é par ou ímpar, permitindo reconstrução completa do ponto na receção.
A troca de chaves públicas deve ser feita sobre um canal autenticado. O ECDH por si só não protege contra ataques man-in-the-middle. Se um atacante substituir a chave pública de Alice pela sua própria antes de Bob a receber, pode estabelecer dois canais ECDH separados e interceptar tudo em claro. Em produção, as chaves públicas são assinadas com chaves de longo prazo (ECDSA, Ed25519) ou transmitidas através de um canal já autenticado como TLS. O artigo sobre Assinaturas Digitais em Node.js com ECDSA e Ed25519 cobre exatamente este passo complementar.
Passo 4 e 5: Calcular o Segredo e Derivar Chave com HKDF
Depois da troca de chaves públicas, cada parte calcula o segredo partilhado usando a sua chave privada e a chave pública do parceiro. O resultado, a coordenada x do ponto da curva resultante, não é uma chave de encriptação direta. É necessário passar por HKDF (HMAC-based Key Derivation Function, RFC 5869) para derivar uma chave simétrica com propriedades criptográficas adequadas.
// src/exchange.js
import { createECDH, hkdfSync, randomBytes } from 'node:crypto';
const CURVA = 'prime256v1';
const ALGORITMO_HASH = 'sha256';
const COMPRIMENTO_CHAVE = 32; // 256 bits para AES-256
// --- Lado Alice ---
const alice = createECDH(CURVA);
alice.generateKeys();
const chavePublicaAlice = alice.getPublicKey(); // Buffer
// --- Lado Bob ---
const bob = createECDH(CURVA);
bob.generateKeys();
const chavePublicaBob = bob.getPublicKey(); // Buffer
// Simula transmissão de chaves públicas pela rede
// (em produção: serializar, assinar e enviar por canal autenticado)
// --- Cálculo do segredo partilhado ---
const segredoAlice = alice.computeSecret(chavePublicaBob);
const segredoBob = bob.computeSecret(chavePublicaAlice);
// Verificação: ambos chegam ao mesmo segredo
console.log('Segredos iguais:', segredoAlice.equals(segredoBob)); // true
console.log('Segredo bruto (hex):', segredoAlice.toString('hex'));
console.log('Comprimento segredo:', segredoAlice.length, 'bytes'); // 32 bytes para P-256
// --- Derivação de chave com HKDF ---
// Salt: aleatório, transmitido em claro junto com a chave pública
const salt = randomBytes(32);
// Info: contexto da derivação; chaves distintas para usos distintos
const info = Buffer.from('ecdh-aes256gcm-v1', 'utf8');
// Derivar chave AES-256 a partir do segredo bruto
// ATENÇÃO: hkdfSync() devolve ArrayBuffer; converter para Buffer antes de usar
const chaveDerivada = Buffer.from(
hkdfSync(
ALGORITMO_HASH,
segredoAlice, // ikm: input key material
salt, // salt aleatório
info, // contexto de uso
COMPRIMENTO_CHAVE
)
);
console.log('\nChave derivada (AES-256):');
console.log(chaveDerivada.toString('hex'));
console.log('Pronta para AES-256-GCM:', chaveDerivada.length, 'bytes');
O salt do HKDF não precisa de ser secreto, mas deve ser diferente em cada sessão. O padrão mais comum é incluir o salt na mensagem inicial junto com a chave pública, em claro. O campo info permite derivar múltiplas chaves independentes do mesmo segredo: 'encryption-key-v1' e 'mac-key-v1' derivam chaves distintas para encriptação e autenticação, mesmo com o mesmo segredo ECDH.
O resultado do HKDF é determinístico: dados os mesmos inputs (segredo, salt, info, comprimento), produz sempre a mesma chave. Isto é intencional. Tanto Alice como Bob, depois de calcular o mesmo segredo partilhado ECDH, podem derivar de forma independente a mesma chave AES-256 sem mais comunicação. A coordenação é implícita na matemática do protocolo.
Passo 6 e 7: Encriptar e Desencriptar com AES-256-GCM
Com a chave AES-256 derivada, o próximo passo é encriptar e autenticar os dados. O modo GCM (Galois/Counter Mode) é obrigatório para qualquer implementação nova em 2026. Ao contrário do CBC, o GCM autentica o texto cifrado com um código de autenticação (authentication tag de 128 bits), tornando impossível modificar dados cifrados sem que a desencriptação falhe. Usar AES-256-CBC em novos projetos é um erro de arquitetura em 2026.
// src/crypto.js
import { createCipheriv, createDecipheriv, randomBytes, createECDH, hkdfSync } from 'node:crypto';
const ALGORITMO = 'aes-256-gcm';
const IV_COMPRIMENTO = 12; // 96 bits: recomendado para GCM pelo NIST SP 800-38D
const TAG_COMPRIMENTO = 16; // 128 bits: authentication tag máximo
/**
* Encripta com AES-256-GCM
* @param {Buffer} chave - 32 bytes derivados por HKDF
* @param {string|Buffer} mensagem - dados em claro
* @param {Buffer} [aad] - dados adicionais autenticados (opcional mas recomendado)
*/
export function encriptar(chave, mensagem, aad = null) {
const iv = randomBytes(IV_COMPRIMENTO);
const cipher = createCipheriv(ALGORITMO, chave, iv, { authTagLength: TAG_COMPRIMENTO });
if (aad) cipher.setAAD(aad);
const textoCifrado = Buffer.concat([cipher.update(mensagem), cipher.final()]);
const tag = cipher.getAuthTag();
return { iv, textoCifrado, tag };
}
/**
* Desencripta e verifica autenticidade
* Lança erro se a tag de autenticação falhar (dados modificados ou chave errada)
*/
export function desencriptar(chave, iv, textoCifrado, tag, aad = null) {
const decipher = createDecipheriv(ALGORITMO, chave, iv, { authTagLength: TAG_COMPRIMENTO });
decipher.setAuthTag(tag);
if (aad) decipher.setAAD(aad);
return Buffer.concat([decipher.update(textoCifrado), decipher.final()]);
}
// --- Demonstração completa ---
const CURVA = 'prime256v1';
const alice = createECDH(CURVA);
alice.generateKeys();
const bob = createECDH(CURVA);
bob.generateKeys();
const segredo = alice.computeSecret(bob.getPublicKey());
const salt = randomBytes(32);
const info = Buffer.from('chat-v1', 'utf8');
const chave = Buffer.from(hkdfSync('sha256', segredo, salt, info, 32));
const mensagem = 'Olá Bob, esta mensagem está encriptada com ECDH + AES-256-GCM';
// AAD: metadados autenticados mas não encriptados (ex: remetente, número de sequência)
const aad = Buffer.from('alice->bob:seq:1', 'utf8');
const { iv, textoCifrado, tag } = encriptar(chave, mensagem, aad);
console.log('Texto cifrado (base64):', textoCifrado.toString('base64'));
console.log('IV (hex):', iv.toString('hex'));
console.log('Auth tag (hex):', tag.toString('hex'));
// Bob desencripta com a mesma chave derivada
const chaveBob = Buffer.from(
hkdfSync('sha256', bob.computeSecret(alice.getPublicKey()), salt, info, 32)
);
const decifrado = desencriptar(chaveBob, iv, textoCifrado, tag, aad);
console.log('\nMensagem decifrada:', decifrado.toString('utf8'));
Os dados adicionais autenticados (AAD) são uma funcionalidade do GCM frequentemente ignorada mas muito útil. Permitem incluir metadados como identificadores de remetente/destinatário, números de sequência ou versões de protocolo na autenticação sem os encriptar. Se um atacante modificar o AAD em trânsito, a desencriptação falha com erro de autenticação. Usa AAD sempre que precisas de autenticar contexto que não precisa de confidencialidade.
Passo 8: Implementar X25519 como Alternativa Moderna
O X25519 usa uma abordagem diferente das curvas P-NIST. Foi desenhado por Daniel J. Bernstein especificamente para resistir a erros de implementação comuns. As curvas Curve25519 têm cofator 8 (em vez de 1 nas curvas NIST), o que simplifica a proteção contra ataques de pequeno subgrupo sem necessitar de validação explícita do ponto. Em Node.js, X25519 usa a API generateKeyPairSync e diffieHellman() em vez de createECDH().
// src/x25519.js
import { generateKeyPairSync, diffieHellman, hkdfSync, randomBytes } from 'node:crypto';
// Gerar par de chaves X25519 usando a API KeyObject (moderna)
function gerarChavesX25519() {
const { privateKey, publicKey } = generateKeyPairSync('x25519');
return { privateKey, publicKey }; // KeyObject: mais flexível que Buffer
}
// Troca ECDH com X25519 via diffieHellman()
function calcularSegredoX25519(chavePrivadaLocal, chavePublicaPar) {
return diffieHellman({
privateKey: chavePrivadaLocal,
publicKey: chavePublicaPar,
});
}
// Exemplo completo X25519
const alice = gerarChavesX25519();
const bob = gerarChavesX25519();
// Alice calcula com a chave pública de Bob
const segredoAlice = calcularSegredoX25519(alice.privateKey, bob.publicKey);
// Bob calcula com a chave pública de Alice
const segredoBob = calcularSegredoX25519(bob.privateKey, alice.publicKey);
console.log('Segredos X25519 iguais:', segredoAlice.equals(segredoBob)); // true
console.log('Comprimento:', segredoAlice.length, 'bytes'); // 32 bytes
// Derivação HKDF: idêntica à de P-256
const salt = randomBytes(32);
const chave = Buffer.from(
hkdfSync('sha256', segredoAlice, salt, Buffer.from('x25519-v1'), 32)
);
console.log('Chave AES-256 derivada de X25519:', chave.toString('hex'));
// Exportar chave pública X25519 para transmissão
const chavePublicaBob = bob.publicKey.export({ type: 'spki', format: 'der' });
console.log('Chave pública Bob (DER, base64):', chavePublicaBob.toString('base64'));
console.log('Comprimento chave pública X25519:', chavePublicaBob.length, 'bytes'); // ~44 bytes (DER)
A principal diferença de API entre P-256 e X25519 está no facto de X25519 usar generateKeyPairSync('x25519') e diffieHellman(), enquanto P-256 usa createECDH('prime256v1') e computeSecret(). O resultado em ambos os casos é um segredo de 32 bytes. Após a derivação HKDF, o processo de encriptação AES-256-GCM é exatamente o mesmo.
Passo 9 e 10: Validação de Chaves e Gestão de Erros
A ausência de validação de chaves é um dos erros mais graves em implementações ECDH. Se não validares que a chave pública recebida pertence à curva correta, podes ser vulnerável a ataques de pequeno subgrupo ou ataques de chave inválida, onde um atacante envia uma chave construída para revelar bits da tua chave privada através de múltiplas sessões.
// src/validacao.js
import { createECDH } from 'node:crypto';
// Comprimentos esperados em bytes por curva e formato
const COMPRIMENTOS_ESPERADOS = {
'prime256v1': { comprimida: 33, naoComprimida: 65 },
'secp384r1': { comprimida: 49, naoComprimida: 97 },
'secp521r1': { comprimida: 67, naoComprimida: 133 },
};
/**
* Verificar formato da chave pública antes de processar
* @throws {Error} se formato inválido
*/
export function verificarFormatoChave(chaveBuffer, curva = 'prime256v1') {
const esperado = COMPRIMENTOS_ESPERADOS[curva];
if (!esperado) {
throw new Error(`Curva não suportada na validação: ${curva}`);
}
const ehComprimida = chaveBuffer.length === esperado.comprimida;
const ehNaoComprimida = chaveBuffer.length === esperado.naoComprimida;
if (!ehComprimida && !ehNaoComprimida) {
throw new Error(
`Comprimento inválido para ${curva}: recebeu ${chaveBuffer.length} bytes, ` +
`esperava ${esperado.comprimida} (comprimida) ou ${esperado.naoComprimida} (não comprimida)`
);
}
const prefixo = chaveBuffer[0];
if (ehComprimida && prefixo !== 0x02 && prefixo !== 0x03) {
throw new Error(`Prefixo inválido para chave comprimida: 0x${prefixo.toString(16)}`);
}
if (ehNaoComprimida && prefixo !== 0x04) {
throw new Error(`Prefixo inválido para chave não comprimida: 0x${prefixo.toString(16)}`);
}
return { valida: true, formato: ehComprimida ? 'comprimida' : 'não-comprimida' };
}
/**
* Validação criptográfica completa: verifica que o ponto pertence à curva
* @throws {Error} com código ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY se inválida
*/
export function validarPontoNaCurva(chaveBuffer, curva = 'prime256v1') {
// Primeiro verifica formato (rápido, sem operações de curva)
verificarFormatoChave(chaveBuffer, curva);
// Depois usa o OpenSSL para validar o ponto (mais lento mas completo)
const local = createECDH(curva);
local.generateKeys();
try {
const segredo = local.computeSecret(chaveBuffer);
if (!segredo || segredo.length === 0) {
throw new Error('Segredo vazio após computeSecret');
}
} catch (err) {
if (err.code === 'ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY') {
throw new Error(`Chave pública inválida (fora da curva ${curva}): ${err.message}`);
}
throw err;
}
return true;
}
// Uso em produção: validar ANTES de calcular o segredo real
import { createECDH as createECDHTest } from 'node:crypto';
const ecdh = createECDHTest('prime256v1');
ecdh.generateKeys();
const chaveValida = ecdh.getPublicKey();
try {
verificarFormatoChave(chaveValida, 'prime256v1');
console.log('Chave válida:', chaveValida.toString('hex').slice(0, 16) + '...');
} catch (err) {
console.error('Chave rejeitada:', err.message);
}
Em sistemas de produção, a validação da chave deve acontecer antes de qualquer cálculo criptográfico. Nunca confies em chaves públicas recebidas de fontes externas sem validação. O Node.js lança ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY quando a chave é inválida, mas apenas na altura em que tentas calcular o segredo. Validar o formato antes (prefixo e comprimento) é mais rápido e evita trabalho desnecessário de CPU. Usa os códigos de erro para dar feedback ao cliente sem expor detalhes internos.
Passo 11: Projeto Completo de Chat Encriptado
O passo final junta tudo num projeto funcional que simula uma troca de mensagens encriptadas ponta-a-ponta usando ECDH. Este é o padrão base usado pelo Signal Protocol e pelo WhatsApp no seu sistema de encriptação de mensagens.
// src/chat-seguro.js
// Projeto completo: chat E2EE com ECDH + HKDF + AES-256-GCM
import {
createECDH,
hkdfSync,
randomBytes,
createCipheriv,
createDecipheriv,
createHash,
} from 'node:crypto';
const CURVA = 'prime256v1';
const HASH = 'sha256';
const TAMANHO_CHAVE = 32;
const TAMANHO_IV = 12;
const TAMANHO_TAG = 16;
class ParticipanteECDH {
#instanciaECDH;
#nome;
constructor(nome) {
this.#nome = nome;
this.#instanciaECDH = createECDH(CURVA);
this.#instanciaECDH.generateKeys();
}
get nome() { return this.#nome; }
// Chave pública comprimida para transmitir ao parceiro
get chavePublica() {
return this.#instanciaECDH.getPublicKey(null, 'compressed');
}
// Fingerprint de 8 bytes para verificação fora-de-banda (método Signal)
get fingerprint() {
return createHash('sha256')
.update(this.chavePublica)
.digest('hex')
.slice(0, 16)
.match(/.{4}/g)
.join(':');
}
// Derivar chave de sessão simétrica a partir da chave pública do parceiro
derivarChaveSessao(chavePublicaParceiro, salt, contexto = 'v1') {
const segredo = this.#instanciaECDH.computeSecret(chavePublicaParceiro);
const info = Buffer.from(`ecdh-chat:${CURVA}:${contexto}`, 'utf8');
return Buffer.from(hkdfSync(HASH, segredo, salt, info, TAMANHO_CHAVE));
}
// Encriptar mensagem com número de sequência para prevenir replay
encriptar(chaveSessao, mensagem, seq) {
const iv = randomBytes(TAMANHO_IV);
const aad = Buffer.from(`seq:${seq}:de:${this.#nome}`, 'utf8');
const cipher = createCipheriv('aes-256-gcm', chaveSessao, iv, {
authTagLength: TAMANHO_TAG,
});
cipher.setAAD(aad);
const cifrado = Buffer.concat([cipher.update(mensagem, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
iv: iv.toString('base64'),
cifrado: cifrado.toString('base64'),
tag: tag.toString('base64'),
seq,
de: this.#nome,
};
}
// Desencriptar e verificar autenticidade
desencriptar(chaveSessao, pacote) {
const iv = Buffer.from(pacote.iv, 'base64');
const cifrado = Buffer.from(pacote.cifrado, 'base64');
const tag = Buffer.from(pacote.tag, 'base64');
const aad = Buffer.from(`seq:${pacote.seq}:de:${pacote.de}`, 'utf8');
const decipher = createDecipheriv('aes-256-gcm', chaveSessao, iv, {
authTagLength: TAMANHO_TAG,
});
decipher.setAuthTag(tag);
decipher.setAAD(aad);
return Buffer.concat([decipher.update(cifrado), decipher.final()]).toString('utf8');
}
}
// --- Simulação da Troca Completa ---
const alice = new ParticipanteECDH('Alice');
const bob = new ParticipanteECDH('Bob');
console.log('=== Troca de Chaves ECDH P-256 ===');
console.log(`Alice fingerprint: ${alice.fingerprint}`);
console.log(`Bob fingerprint: ${bob.fingerprint}`);
console.log('(Comparar estes valores por voz/pessoalmente para confirmar ausência de MITM)');
// Salt partilhado: gerado por Alice, transmitido junto com a chave pública
const saltSessao = randomBytes(32);
// Ambos derivam a mesma chave de sessão de forma independente
const chaveAlice = alice.derivarChaveSessao(bob.chavePublica, saltSessao);
const chaveBob = bob.derivarChaveSessao(alice.chavePublica, saltSessao);
console.log('\nChaves de sessão iguais:', chaveAlice.equals(chaveBob)); // true
console.log('Chave de sessão (hex):', chaveAlice.toString('hex'));
// Troca de mensagens
console.log('\n=== Mensagens Encriptadas ===');
const p1 = alice.encriptar(chaveAlice, 'Olá Bob! Mensagem protegida com ECDH.', 1);
console.log('\nAlice envia (cifrado):', p1.cifrado.slice(0, 20) + '...');
const d1 = bob.desencriptar(chaveBob, p1);
console.log('Bob decifrou:', d1);
const p2 = bob.encriptar(chaveBob, 'Recebi com sucesso, Alice!', 2);
const d2 = alice.desencriptar(chaveAlice, p2);
console.log('Alice decifrou:', d2);
Executa o projeto com node src/chat-seguro.js. A saída mostrará os fingerprints das chaves, a confirmação de que as chaves de sessão derivadas de forma independente são iguais, e as mensagens decifradas com sucesso. O fingerprint de 8 bytes (format xxxx:xxxx:xxxx:xxxx) é o mecanismo que o Signal usa para verificação fora-de-banda: os utilizadores comparam estes códigos por chamada de voz ou presencialmente para confirmar a ausência de um atacante intermediário.
ECDH vs RSA: Comparação Técnica para 2026
A escolha entre ECDH e RSA não é apenas técnica. Afeta performance, compatibilidade, tamanho dos dados transmitidos e manutenção a longo prazo. A tabela seguinte compara os dois mecanismos para os casos de uso mais comuns em 2026.
| Critério | ECDH (P-256) | RSA-2048 | RSA-3072 |
|---|---|---|---|
| Nível de segurança | 128 bits | 112 bits | 128 bits |
| Tamanho da chave pública | 33 bytes (comprimida) | 256 bytes | 384 bytes |
| Forward secrecy nativa | Sim (chaves efémeras) | Não | Não |
| Suporte em TLS 1.3 | Sim (preferencial) | Não (removido) | Não (removido) |
| Latência p95 (caso 2025) | 194 ms | 318 ms (TLS 1.2) | Mais lento |
| CPU balanceador de carga | 28% menos | Referência | Referência |
| Falhas de handshake | 0,4% | 1,2% (TLS 1.2) | Não aplicável |
| Resistência pós-quântica | Nenhuma | Nenhuma | Nenhuma |
O RSA continua válido para assinatura digital em certificados X.509 e para autenticação, mas como mecanismo de troca de chaves foi substituído pelo ECDH no TLS 1.3. Para novos sistemas que não precisam de compatibilidade com TLS 1.2, ECDH (P-256 ou X25519) é sempre a escolha correta. O artigo sobre AES-256 vs ChaCha20 aborda a escolha do algoritmo simétrico a usar após a derivação da chave ECDH.
5 Erros Comuns que Comprometem a Segurança do ECDH
A maioria das vulnerabilidades em implementações ECDH não vem de falhas matemáticas mas de erros de implementação. Estes são os cinco mais frequentes em projetos Node.js auditados.
Erro 1: Usar o segredo bruto como chave de encriptação. O segredo calculado por computeSecret() não é uma chave AES direta. As coordenadas de pontos de curvas elípticas têm distribuição não uniforme que pode ser explorada em ataques com múltiplas observações. É sempre obrigatório passar o segredo por HKDF antes de o usar como chave simétrica. O padrão errado const chave = ecdh.computeSecret(parPublico) seguido diretamente de createCipheriv('aes-256-gcm', chave, iv) está incorreto mesmo que a chave tenha 32 bytes de comprimento correto.
Erro 2: Reutilizar o par de chaves ECDH entre sessões. O ECDH efémero garante forward secrecy porque as chaves são destruídas após cada sessão. Guardar a chave privada ECDH em disco e reutilizá-la nas sessões seguintes transforma o protocolo num sistema estático que perde toda a proteção de forward secrecy. As chaves ECDH devem ser geradas de novo a cada sessão ou negociação de chaves.
Erro 3: Não validar a chave pública recebida antes de usar. Um atacante pode enviar um ponto inválido (fora da curva) ou um ponto de pequeno subgrupo, reduzindo drasticamente o espaço do segredo possível. Valida sempre o formato (prefixo e comprimento) antes de passar ao computeSecret(), e captura o erro ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY com mensagem adequada ao cliente.
Erro 4: Salt fixo ou ausente no HKDF. Um salt fixo no HKDF reduz a derivação a uma função determinística apenas no segredo ECDH. Se dois pares distintos produzirem o mesmo segredo (improvável mas possível em teoria), derivarão a mesma chave AES. O salt deve ser aleatório, gerado com randomBytes(32) a cada sessão, e transmitido em claro junto com a chave pública. Sem salt aleatório, perdes uma camada de proteção importante.
Erro 5: Esquecer de converter hkdfSync() para Buffer. Este é o erro de API mais frequente em Node.js com HKDF. O hkdfSync() devolve um ArrayBuffer, não um Buffer. O createCipheriv() espera um Buffer e lança um TypeError se receber ArrayBuffer diretamente. A solução é sempre Buffer.from(hkdfSync(...)) antes de usar o resultado em operações criptográficas.
Troubleshooting: 8 Problemas e Soluções
Problema 1: ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY
A chave pública recebida não pertence à curva especificada, tem comprimento errado, ou o prefixo é inválido. Solução: verifica o comprimento antes de usar (33 bytes para P-256 comprimida, 65 bytes não comprimida). Confirma que ambas as partes usam a mesma curva. Testa o prefixo com Buffer.from(chave, 'base64')[0]: deve ser 0x02, 0x03 (comprimida) ou 0x04 (não comprimida).
Problema 2: Segredos diferentes entre Alice e Bob
Uma das partes usou a sua própria chave pública em vez da do parceiro, ou as curvas são diferentes. Solução: adiciona logs com chavePublica.toString('hex') nos dois lados antes de calcular o segredo. O debug mais útil é verificar se o hash SHA-256 da chave pública recebida em hex é o mesmo em ambos os lados do canal.
Problema 3: Error: Invalid IV length em AES-256-GCM
O IV para GCM deve ter exatamente 12 bytes (96 bits). O OpenSSL 3.x rejeita outros comprimentos. Solução: usa sempre randomBytes(12) para gerar o IV. Verifica se não estás a truncar ou a codificar incorretamente o IV em base64 durante a transmissão.
Problema 4: Unsupported state or unable to authenticate data
A authentication tag GCM não corresponde: os dados foram modificados em trânsito, a chave está errada, ou o AAD é diferente entre encriptação e desencriptação. Solução: verifica que o AAD é byte-a-byte idêntico nos dois lados. Confirma que a chave derivada por HKDF usa exatamente o mesmo salt, info e comprimento. Compara o hex da chave nos dois processos antes da encriptação.
Problema 5: X25519 não disponível
Erro: Invalid EC curve name ou crypto.generateKeyPairSync is not a function. Causa: Node.js versão anterior a 10.x para X25519, ou OpenSSL compilado sem suporte. Solução: atualiza para Node.js 18.x LTS ou superior. Testa com node -e "require('crypto').generateKeyPairSync('x25519')". Usa nvm para gerir versões de Node.js em paralelo.
Problema 6: Performance lenta com P-521
O P-521 usa aritmética de 521 bits que não se alinha com arquiteturas de 64 bits e é consideravelmente mais lento que P-256 ou X25519. Para a maioria das aplicações, 128 bits de segurança (P-256) é adequado até à migração para algoritmos pós-quânticos. Usa P-521 apenas se tiveres requisitos regulatórios específicos que exijam 256 bits de segurança clássica.
Problema 7: TypeError: The "key" argument must be...
O resultado de hkdfSync() devolve um ArrayBuffer, não um Buffer. O createCipheriv espera um tipo específico. Solução: converte sempre com Buffer.from(hkdfSync(...)) antes de usar em createCipheriv. Este é um dos erros de API mais comuns ao usar HKDF síncrono em Node.js 18 e versões posteriores.
Problema 8: Encoding inconsistente causa chaves diferentes
getPublicKey('hex', 'compressed') e getPublicKey(null, 'compressed') retornam o mesmo valor em tipos diferentes (string hex vs Buffer). Se um lado converte de hex para Buffer e o outro passa diretamente, podem surgir diferenças nas operações seguintes. Solução: usa sempre Buffer internamente e converte para string apenas na serialização final. O encoding base64 é recomendado para transmissão em JSON ou HTTP headers.
Dicas Avançadas e Casos de Uso Reais
Derivar Múltiplas Chaves do Mesmo Segredo ECDH
Um único segredo ECDH pode gerar múltiplas chaves independentes alterando o campo info do HKDF. Num protocolo bidirecional, podes derivar chaves separadas para encriptação de Alice para Bob e de Bob para Alice, eliminando a possibilidade de um lado usar inadvertidamente as chaves do outro.
// Derivar múltiplas chaves independentes do mesmo segredo
const segredo = ecdh.computeSecret(chavePublicaPar);
const salt = randomBytes(32);
// Cada campo 'info' diferente produz uma chave criptograficamente independente
const chaveEncAliceBob = Buffer.from(hkdfSync('sha256', segredo, salt, Buffer.from('enc:alice->bob:v1'), 32));
const chaveEncBobAlice = Buffer.from(hkdfSync('sha256', segredo, salt, Buffer.from('enc:bob->alice:v1'), 32));
const chaveMAC = Buffer.from(hkdfSync('sha256', segredo, salt, Buffer.from('mac:v1'), 32));
// Todas as três chaves são criptograficamente independentes entre si
console.log('Chaves iguais?', chaveEncAliceBob.equals(chaveEncBobAlice)); // false
ECDH em APIs REST e Microserviços
Em arquiteturas de microserviços, o ECDH é usado para encriptação ponta-a-ponta de dados sensíveis entre serviços, mesmo quando a comunicação já corre sobre TLS. O serviço A inclui a chave pública ECDH no header HTTP (X-ECDH-Public-Key), o serviço B responde com a sua, e ambos estabelecem um segredo partilhado para encriptar o payload sensível. Isto protege os dados mesmo contra um atacante que tenha comprometido o balanceador de carga TLS ou tenha acesso aos logs do gateway. O artigo sobre mTLS em Node.js: TLS 1.3 cobre a camada de transporte que complementa este padrão.
Rotação de chaves e Double Ratchet: Em sistemas de mensagens seguras como o Signal, o ECDH básico é estendido com um mecanismo de Double Ratchet. A cada mensagem, uma nova troca ECDH efémera é combinada com a chave de sessão anterior via HKDF para derivar uma nova chave. Isto garante que comprometer uma chave de sessão não expõe mensagens passadas (backward secrecy) nem futuras (forward secrecy em granularidade por mensagem). O padrão base construído neste tutorial é o bloco fundamental de qualquer implementação de ratchet.
Para conformidade com as recomendações da OWASP para armazenamento criptográfico, usa sempre chaves efémeras ECDH, deriva chaves de sessão com HKDF, e cifra com AES-256-GCM ou ChaCha20-Poly1305. As diretrizes do NIST para padrões criptográficos recomendam P-256 ou P-384 para sistemas que precisem de certificação governamental. A especificação técnica de X25519 e X448 está no RFC 7748 do IETF.
FAQ: Perguntas Frequentes sobre ECDH em Node.js
Qual a diferença entre ECDH e ECDSA em Node.js?
O ECDH é um protocolo de troca de chaves: permite a duas partes chegarem ao mesmo segredo sem nunca o transmitir. O ECDSA é um protocolo de assinatura digital: permite assinar dados de forma que qualquer um com a chave pública possa verificar a autenticidade. São operações complementares mas distintas. Em TLS 1.3, o ECDH estabelece o segredo da sessão enquanto o ECDSA (ou Ed25519) autentica o servidor. Para implementar ECDSA em Node.js, o artigo sobre Assinaturas Digitais em Node.js cobre o processo completo.
Posso usar o segredo ECDH diretamente como chave AES sem HKDF?
Não. O segredo partilhado ECDH é a coordenada x de um ponto de curva elíptica. Estes valores não têm distribuição uniforme como bytes aleatórios. Usar o segredo bruto como chave AES pode introduzir vulnerabilidades subtis exploráveis com múltiplas observações de sessões com o mesmo segredo estático. O HKDF garante que a chave derivada tem distribuição uniforme independentemente das propriedades estatísticas do segredo ECDH. Esta obrigatoriedade está documentada nas especificações do NIST e do IETF.
O ECDH é resistente a computadores quânticos?
Não. O algoritmo de Shor, executado num computador quântico suficientemente grande, consegue resolver o ECDLP e quebrar o ECDH em tempo polinomial. O NIST padronizou em 2024 os primeiros algoritmos pós-quânticos (ML-KEM, ML-DSA, SLH-DSA) que substituirão o ECDH para troca de chaves. A transição está em curso, com sistemas híbridos que combinam ECDH e ML-KEM a serem adotados em TLS 1.3 via extensões de negociação de grupo. Para o contexto atual de 2026, o ECDH continua seguro contra todos os ataques clássicos conhecidos.
Como serializar as chaves ECDH para transmissão em JSON?
A forma mais simples é base64. Usa ecdh.getPublicKey('base64', 'compressed') para obter a chave pública como string base64. Na receção, converte com Buffer.from(chaveBase64, 'base64') antes de passar a computeSecret(). Inclui também o nome da curva, o salt HKDF e o campo info no mesmo objeto JSON para que o receptor tenha toda a informação necessária sem comunicação adicional. Um objeto de handshake típico tem a forma { curva, chavePublica, salt, info, versao }.
Qual a diferença de segurança entre X25519 e P-256?
Ambos oferecem 128 bits de segurança clássica, suficiente contra todos os ataques não-quânticos conhecidos. A diferença está no design e na confiança. As curvas P-NIST foram padronizadas com parâmetros cuja origem não é completamente transparente. O X25519 foi desenhado com parâmetros escolhidos de forma verificável, auditável e resistente a erros de implementação comuns como ataques de timing. Na prática, ambas são consideradas seguras para uso geral em 2026, mas X25519 é preferido em novos protocolos onde a transparência e a resistência a erros de implementação são prioritárias.
Como depurar erros de autenticação GCM em produção?
O erro Unsupported state or unable to authenticate data pode ter várias causas. Segue esta ordem de verificação: (1) compara o hex da chave AES nos dois lados com logging antes de encriptar e desencriptar; (2) verifica que o IV transmitido é o mesmo comparando hex nos dois processos; (3) confirma que o AAD é byte-a-byte idêntico, incluindo encoding e ordenação; (4) verifica que a authentication tag não foi truncada ou corrompida na transmissão (deve ter exatamente 16 bytes em buffer). Um script de diagnóstico que logue todos estes valores antes e depois da transmissão é a forma mais rápida de isolar a causa.
Posso usar ECDH com autenticação JWT?
Sim. Um padrão comum é usar JWT para autenticação de sessão (quem és) e ECDH para encriptação ponta-a-ponta do payload (o que enviaste). O JWT viaja em claro no header HTTP e é verificado pelo servidor via HMAC-SHA256 ou RSA. O payload encriptado com ECDH só é legível pelas partes com as chaves privadas corretas, mesmo que o JWT seja válido e interceptado. Esta separação mantém a auditabilidade do JWT enquanto protege o conteúdo sensível contra acesso por terceiros com acesso ao canal TLS.
Cobertura Relacionada
Para aprofundar os conceitos deste tutorial, consulta estes artigos do arquivo de criptografia:
- Assinaturas Digitais em Node.js: ECDSA e Ed25519 em 12 Passos [2026]
- AES-256 vs ChaCha20: 3x Mais Rápido em Servidores, Qual Escolher [2026]
- SHA-256 vs SHA-3: 2373 vs 686 MB/s, Qual Escolher [2026]
- mTLS em Node.js: TLS 1.3 em 12 Passos [2026]
- Autenticação SSH com Chaves Ed25519: 12 Passos [2026]
- Autenticação de Dois Fatores em Node.js: 12 Passos [2026]




