← Todos os textos

Como não desencriptar o WhatsApp Web (e ganhar na mesma)

Nesta página

Ou: uma história de 6 000 palavrões e uma vitória acidental

Disse “foda-se” umas 6 000 vezes esta noite.

Mais coisa, menos coisa. Uma delas foi provavelmente de celebração, as outras foram de tentar fazer engenharia inversa ao armazenamento de dados do WhatsApp Web. Não era isso que tinha planeado quando acordei. Mas a curiosidade é uma droga e pêras.

Esta noite foi a primeira vez que soube que o WhatsApp usa o Signal Protocol.

Fixe! Também: foda-se.

Vamos recuar.

O objetivo original

Estava a trabalhar numa integração nova para o Jamie, o meu assistente pessoal com IA. O objetivo era simples: ir buscar as mensagens do WhatsApp, detetar referências a horários (“vamos na segunda às 3 da tarde”) e adicioná-los automaticamente ao teu calendário.

Fácil, certo?

Hahahahaha.

Parte 1: Quem não sabe é como quem não vê (IndexedDB)

Comecei pelo navegador. O WhatsApp Web guarda os dados no IndexedDB, e achei que ia só… espreitar lá para dentro.

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 criptográficas (ai, ai)
  • wawc → metadados

Tirei alguns 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
  }
}

Está tudo encriptado.

Todas. As. Mensagens.

E não é só encriptado: é bem encriptado. Entropia alta, tamanho dinâmico, esquema consistente.

Nota: o IndexedDB continua a ter um papel na implementação final. Uso-o para acompanhar o estado da sincronização, confirmar quando o carregamento das mensagens terminou de facto e evitar trabalhar com dados parciais. O segredo é perceber quando é que o cliente estabilizou por completo.

Parte 2: Vamos tentar força bruta, na mesma

Como qualquer pessoa sensata sem qualquer respeito pelo próprio tempo, tentei desencriptar as mensagens diretamente.

Passo 1: Apanhar a chave mestra

const masterKey = localStorage.getItem('WebEncKeySalt');
// A 172-byte base64 string

Passo 2: Usá-la em 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 de raiz em 2 horas (nada de mais)

Depois de pesquisar 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 guardadas no signal-storage, repartidas por:

  • session-store
  • identity-store
  • prekey-store
  • signal-meta-store

Fiz engenharia inversa às estruturas das sessões:

{
  address: "192432234885263@lid.0",
  session: {
    rootKey: Uint8Array(32),
    sendChain: { chainKey: ... },
    recvChains: [...]
  }
}

Em teoria, é possível.

Na prática, construir um cliente Signal Protocol funcional e replicar a derivação de chaves do WhatsApp é como reimplementar a encriptação ponta a ponta de raiz.

Por isso, claro, gastei uma hora a tentar.

Parte 4: Espera lá… O DOM sabe coisas

Foi aqui que a coisa mudou.

Se consigo ver as mensagens desencriptadas no navegador… então foi o navegador que as desencriptou.

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

Mensagens desencriptadas, ali no DOM, à vista de todos.

<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 difícil: o WhatsApp baralha os nomes das classes. Clássico do Facebook. Juntando as diferenças de estrutura da interface entre idiomas, apontar sempre ao elemento certo é uma dor de cabeça enorme. Resolver isto exigiu uma abstração cuidadosa, correspondência aproximada de elementos e mais do que umas quantas experiências.

Não vou entrar na abordagem exata, mas resolvi isto o suficiente para que um modelo LLaMA pequeno, a correr no mesmo servidor, consiga extrair os dados estruturados relevantes sem enviar nada para lado nenhum. E é aí que está a vitória.

Parte 5: Arquitetura da integração

Com a abordagem do DOM a funcionar, montei o fluxo completo:

  1. Lançar o WhatsApp Web no Puppeteer
  2. Autenticar com o código QR
  3. Esperar que a sincronização inicial estabilize
  4. Ligar um observador de DOM para extrair as mensagens
  5. Filtrar as mensagens recebidas
  6. Enviar para o analisador de intenções de calendário

Aqui fica um exemplo de código para detetar a 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);
    };
  }
});

Observa as operações criptográficas para detetar quando a desencriptação assentou, uma maneira esperta de saber quando a sincronização acabou.

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 notas

Isto não é uma operação leve.

Usar o Puppeteer para emular um navegador e manter uma sessão de WhatsApp ativa por utilizador é… ousado. É também, provavelmente, a pior escolha de arquitetura para algo que queres que seja rápido, reativo e de baixa latência. Manter uma instância completa do Chrome é pesado. Preservar o estado do navegador entre execuções é complexo. E descobrir como manter tudo sincronizado, isolado e ainda assim rápido foi, sem dúvida, a parte mais difícil de tudo isto.

Mas funciona.

Há agora um bocadinho de molho secreto que deixa um modelo LLaMA local e pequeno analisar as mensagens e detetar intenções diretamente na máquina que corre a instância do navegador. Sem chamadas a APIs externas, sem dados de mensagens a sair da máquina, e tudo funciona em apenas uns quantos blocos de 10 s. (Quem me dera poder dizer em tempo real.)

Em breve, os utilizadores vão poder aproveitar isto. E que pesadelo foi chegar aqui.

Mas, a sério, vale a pena. Há muito contexto pessoal enterrado nas tuas mensagens e agora podemos fazer coisas com significado com ele. Por exemplo… lembrar o que o teu par te pediu para trazer para o jantar há 300 mensagens.

(Spoiler: fiz mesmo isto com ele. Salvou-me a vida.)

Reflexões finais

Comecei isto a pensar que ia desencriptar mensagens do WhatsApp, desisti, construí um scraper de DOM e… acabei por conseguir a desencriptação enquanto escrevia este post.

Reviravolta.

A questão é esta: o WhatsApp faz MUITO para tornar o scraping do DOM extremamente difícil. Baralha os nomes das classes, altera a estrutura dos elementos conforme o idioma e, em geral, torna a vida num inferno a quem tenta extrair dados da interface de forma fiável. A minha solução com o DOM funcionou, mas ia ser sempre um jogo do gato e do rato.

Com a desencriptação a sério? Extraímos diretamente da fonte dos dados. Saída estruturada, análise fiável, sem andar à caça de classes CSS com nomes aleatórios nem de lidar com alterações na interface. É uma melhoria séria para a fiabilidade a longo prazo.

A abordagem da desencriptação significa:

  • Chega de seletores CSS frágeis que partem a cada atualização do WhatsApp
  • Dados estruturados em vez de analisar HTML
  • Melhor desempenho, sem observação constante do DOM
  • Histórico completo de mensagens, não só o que está desenhado no ecrã

Moral da história?

Às vezes, a solução “impossível” vale mesmo a pena. A abordagem do DOM levou-me 80% do caminho e ensinou-me o suficiente sobre a arquitetura do WhatsApp para conseguir os 20% que faltavam.

E sim, praguejar continua a ajudar.


A implementação completa da desencriptação fica para outro post, mas por agora, funciona! Só digo que o HKDF e o wawc_db_enc podem ir à vida e morrer tranquilamente nalgum canto.