← Todos os textos

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-store
  • identity-store
  • prekey-store
  • signal-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:

  1. Abrir o WhatsApp Web no Puppeteer
  2. Autenticar com o QR code
  3. Esperar a sincronização inicial estabilizar
  4. Ligar um observador de DOM para extrair as mensagens
  5. Filtrar as mensagens recebidas
  6. 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.