Como não descriptografar o WhatsApp Web (e ainda assim ganhar)
Nesta página
Ou: uma história de 6.000 palavrões e uma vitória por acidente
Hoje à noite eu falei “porra” umas 6.000 vezes.
Mais ou menos. Uma delas foi provavelmente de comemoração. O resto veio de tentar fazer engenharia reversa de como o WhatsApp Web guarda os dados. Eu não pretendia fazer isso quando acordei. Mas curiosidade é uma droga e tanto.
Hoje foi a primeira vez que descobri que o WhatsApp usa o Signal Protocol.
Legal! Também: porra.
Vamos voltar um pouco.
O objetivo original
Eu estava trabalhando numa integração nova para o Jamie, meu assistente pessoal de IA. O objetivo era simples: puxar as mensagens do WhatsApp, detectar pistas de horário (“vamos fazer segunda às 3 da tarde”) e adicionar na sua agenda automaticamente.
Fácil, né?
Rárárárá.
Parte 1: Ignorância é uma bênção (IndexedDB)
Comecei pelo navegador. O WhatsApp Web guarda dados no IndexedDB, e pensei em só… dar uma espiada.
async function exploreWhatsAppDatabases() {
const databases = await indexedDB.databases();
console.log('Found databases:', databases);
// ['model-storage', 'signal-storage', 'wawc']
}
Até aqui, tudo bem.
- model-storage → guarda as mensagens
- signal-storage → guarda as chaves de criptografia (ai, ai)
- wawc → metadados
Tirei uns dados de exemplo do model-storage:
{
id: "false_AAAAHHHHHHHHHHHHHHH@g.us_OBFUSCATINGFOROBVIOUSREASONS_00000000000@c.us",
msgRowOpaqueData: {
_data: ArrayBuffer(160),
iv: Uint8Array(16),
_keyId: 1,
_scheme: 1
}
}
Tudo está criptografado.
Toda. Santa. Mensagem.
E não é só criptografado, é bem criptografado. Entropia alta, tamanho dinâmico, esquema consistente.
Nota: o IndexedDB ainda tem um papel na implementação final. Uso ele para acompanhar o estado da sincronização, confirmar quando o carregamento das mensagens realmente terminou e evitar trabalhar com dados parciais. O segredo é saber quando o cliente estabilizou de vez.
Parte 2: Vamos tentar força bruta mesmo assim
Como qualquer pessoa sensata que não liga para o próprio tempo, tentei descriptografar as mensagens direto.
Passo 1: pegar a chave mestra
const masterKey = localStorage.getItem('WebEncKeySalt');
// A 172-byte base64 string
Passo 2: usar a chave no AES-GCM
async function attemptDirectDecryption(message, masterKey) {
try {
const keyBytes = atob(masterKey);
const keyArray = new Uint8Array(keyBytes.length);
for (let i = 0; i < keyBytes.length; i++) {
keyArray[i] = keyBytes.charCodeAt(i);
}
const aesKey = await crypto.subtle.importKey(
'raw',
keyArray.slice(0, 32),
{ name: 'AES-GCM' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: message.msgRowOpaqueData.iv,
tagLength: 128,
},
aesKey,
message.msgRowOpaqueData._data
);
return new TextDecoder().decode(decrypted);
} catch (err) {
console.error('fuck:', err);
}
}
Resultado:
fuck: OperationError: AES-GCM decryption failed
Parte 3: Vamos aprender o Signal Protocol do zero em 2 horas (sem pressão)
Depois de cavar um pouco, percebi que o WhatsApp usa o Signal Protocol, com Double Ratchet, chain keys, identity keys, prekeys… o pacote completo.
As chaves de sessão ficam no signal-storage, divididas entre:
session-storeidentity-storeprekey-storesignal-meta-store
Fiz engenharia reversa das estruturas de sessão:
{
address: "192432234885263@lid.0",
session: {
rootKey: Uint8Array(32),
sendChain: { chainKey: ... },
recvChains: [...]
}
}
Na teoria, dá para fazer.
Na prática, montar um cliente Signal Protocol funcional e replicar a derivação de chaves do WhatsApp é reimplementar a criptografia de ponta a ponta do zero.
Então, claro, gastei uma hora tentando.
Parte 4: Peraí… o DOM sabe das coisas
Foi a virada.
Se eu consigo ver as mensagens descriptografadas no navegador… então o navegador descriptografou.
Entra o observador de DOM:
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
const el = node.querySelector('.copyable-text[data-pre-plain-text]');
const content = node.querySelector(
'._ao3e.selectable-text.copyable-text span'
);
if (el && content) {
console.log({
timestamp: el.getAttribute('data-pre-plain-text'),
message: content.textContent,
});
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
E funcionou. Puta merda.
Mensagens descriptografadas, ali paradas no DOM.
<div
class="copyable-text"
data-pre-plain-text="[23:41, 06/08/2025] Will Hackett: "
>
<span class="_ao3e selectable-text copyable-text">
<span>Hey are you still good for tomorrow?</span>
</span>
</div>
Agora vem a parte chata: o WhatsApp randomiza os nomes das classes. Coisa clássica do Facebook. Somando isso às diferenças de localização na estrutura da interface entre idiomas, acertar o alvo de forma consistente dá uma dor de cabeça enorme. Contornar isso exigiu uma abstração cuidadosa, correspondência aproximada de elementos e vários experimentos.
Não vou entrar no método exato, mas basta dizer que resolvi o suficiente para que um modelo LLaMA pequeno, rodando no mesmo servidor, extraia os dados estruturados relevantes sem enviar nada para outro lugar. E essa é a vitória.
Parte 5: Arquitetura da integração
Com a abordagem do DOM funcionando, montei o fluxo completo:
- Abrir o WhatsApp Web no Puppeteer
- Autenticar com o QR code
- Esperar a sincronização inicial estabilizar
- Ligar um observador de DOM para extrair as mensagens
- Filtrar as mensagens recebidas
- Mandar para o parser de intenção de calendário
Este é um trecho de exemplo da detecção de sincronização:
await page.evaluate(() => {
window.__lastCryptoOperation = Date.now();
const origDecrypt = window.crypto?.subtle?.decrypt;
if (origDecrypt) {
window.crypto.subtle.decrypt = function (...args) {
window.__lastCryptoOperation = Date.now();
return origDecrypt.apply(this, args);
};
}
});
Ele observa as operações de criptografia para detectar quando a descriptografia se acalmou, um jeito esperto de saber quando a sincronização terminou.
Parte 6: A função final de extração
private async extractMessagesFromDOM(page: Page): Promise<WhatsAppMessage[]> {
const domMessages = await page.evaluate(() => {
const messageElements = document.querySelectorAll('.copyable-text[data-pre-plain-text]');
const messages = [];
messageElements.forEach(element => {
const prePlainText = element.getAttribute('data-pre-plain-text');
const content = element.querySelector('._ao3e.selectable-text.copyable-text span')?.textContent;
const isOutgoing = element.closest('.message-out') !== null;
if (content && prePlainText && !isOutgoing) {
messages.push({ prePlainText, content });
}
});
return messages;
});
return parseMessages(domMessages);
}
Mais algumas observações
Isso não é uma operação leve.
Usar o Puppeteer para emular um navegador e manter uma sessão ativa do WhatsApp por usuário é… ousado. Também é provavelmente a pior escolha de arquitetura para algo que você quer que pareça rápido, responsivo e de baixa latência. Manter uma instância inteira do Chrome pesa. Preservar o estado do navegador entre execuções é complicado. E descobrir como manter tudo sincronizado, isolado e ainda com bom desempenho foi talvez a parte mais difícil de todas.
Mas funciona.
Agora tem um pouco de molho secreto ali que deixa um modelo LLaMA pequeno e local fazer o parsing das mensagens e a detecção de intenção direto no host que roda a instância do navegador. Sem chamadas a APIs externas, sem dado de mensagem saindo da máquina, e tudo roda em alguns 10s de segundos. (Queria poder dizer que é em tempo real.)
Os usuários vão poder aproveitar isso em breve. E que pesadelo foi chegar até aqui.
Mas, sinceramente, valeu a pena. Tem muito contexto pessoal enterrado nas suas mensagens, e agora dá para fazer coisas úteis com ele. Tipo… lembrar o que seu parceiro ou parceira pediu para você comprar para o jantar 300 mensagens atrás.
(Spoiler: eu usei isso mesmo. Salvou minha vida.)
Considerações finais
Comecei achando que ia descriptografar as mensagens do WhatsApp, desisti, construí um scraper de DOM no lugar… e aí consegui quebrar a descriptografia enquanto escrevia este post.
Reviravolta.
O ponto é este: o WhatsApp se esforça MUITO para tornar o scraping do DOM extremamente difícil. Randomiza nomes de classe, muda a estrutura dos elementos entre localizações e, de modo geral, infernizam a vida de quem tenta extrair dados da interface de forma confiável. Minha solução com DOM funcionou, mas sempre ia ser um jogo de gato e rato.
Com a descriptografia de verdade, porém, extraímos direto da fonte dos dados. Saída estruturada, parsing confiável, sem caçar classes CSS de nome aleatório nem lidar com mudanças na interface. É uma melhoria séria na confiabilidade a longo prazo.
A abordagem da descriptografia significa:
- Chega de seletores CSS frágeis que quebram a cada atualização do WhatsApp
- Dados estruturados em vez de parsear HTML
- Desempenho melhor, sem observar o DOM o tempo todo
- Histórico completo de mensagens, não só o que está renderizado na tela
Moral da história?
Às vezes a solução “impossível” vale a pena depois de tudo. A abordagem do DOM me levou 80% do caminho e me ensinou o bastante sobre a arquitetura do WhatsApp para quebrar os 20% que faltavam.
E sim, xingar ainda ajuda.
A implementação completa da descriptografia fica para outro post. Por enquanto, funciona! Mas já adianto que HKDF e wawc_db_enc podem ir morrer em algum canto.