Cómo no descifrar WhatsApp Web (y ganar igualmente)
En esta página
O: una historia de 6000 palabrotas y una victoria accidental
Esta noche he dicho “joder” unas 6000 veces.
Más o menos. Una fue probablemente de celebración, el resto venía de intentar hacer ingeniería inversa del almacenamiento de datos de WhatsApp Web. No lo tenía previsto al levantarme. Pero la curiosidad es una droga de las duras.
Esta noche descubrí que WhatsApp usa el protocolo Signal.
¡Genial! Y también: joder.
Vamos por partes.
El objetivo original
Estaba trabajando en una nueva integración para Jamie, mi asistente personal con IA. El objetivo era sencillo: traer los mensajes de WhatsApp, detectar referencias temporales (“quedamos el lunes a las 3 de la tarde”) y añadirlas al calendario automáticamente.
Fácil, ¿no?
Jajajajaja.
Parte 1: Ojos que no ven (IndexedDB)
Empecé por el navegador. WhatsApp Web guarda los datos en IndexedDB, y pensé que bastaría con echar un vistazo.
async function exploreWhatsAppDatabases() {
const databases = await indexedDB.databases();
console.log('Found databases:', databases);
// ['model-storage', 'signal-storage', 'wawc']
}
Hasta aquí, bien.
- model-storage → guarda los mensajes
- signal-storage → guarda las claves criptográficas (uy)
- wawc → metadatos
Saqué unos datos de muestra de model-storage:
{
id: "false_AAAAHHHHHHHHHHHHHHH@g.us_OBFUSCATINGFOROBVIOUSREASONS_00000000000@c.us",
msgRowOpaqueData: {
_data: ArrayBuffer(160),
iv: Uint8Array(16),
_keyId: 1,
_scheme: 1
}
}
Todo está cifrado.
Todos. Y cada. Uno. De los mensajes.
Y no solo cifrado: bien cifrado. Entropía alta, tamaño dinámico, esquema coherente.
Nota: IndexedDB sigue teniendo un papel en la implementación final. Lo uso para seguir el estado de la sincronización, confirmar cuándo ha terminado de cargar los mensajes y evitar trabajar con datos a medias. La clave es saber cuándo se ha estabilizado el cliente.
Parte 2: Probemos a reventarlo por fuerza bruta
Como cualquier persona cabal que no respeta su propio tiempo, intenté descifrar los mensajes directamente.
Paso 1: Coger la clave maestra
const masterKey = localStorage.getItem('WebEncKeySalt');
// A 172-byte base64 string
Paso 2: Usarla en 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: Aprendamos el protocolo Signal desde cero en 2 horas (nada del otro mundo)
Tras rascar un poco, me di cuenta de que WhatsApp usa el protocolo Signal, con su Double Ratchet, sus claves de cadena, sus claves de identidad, sus prekeys… el pack completo.
Las claves de sesión viven en signal-storage, repartidas entre:
session-storeidentity-storeprekey-storesignal-meta-store
Hice ingeniería inversa de las estructuras de sesión:
{
address: "192432234885263@lid.0",
session: {
rootKey: Uint8Array(32),
sendChain: { chainKey: ... },
recvChains: [...]
}
}
En teoría, se puede hacer.
En la práctica, montar un cliente del protocolo Signal que funcione y replicar la derivación de claves de WhatsApp equivale a reimplementar el cifrado de extremo a extremo desde cero.
Así que, como es natural, perdí una hora intentándolo.
Parte 4: Un momento… El DOM sabe cosas
Este fue el punto de inflexión.
Si veo los mensajes descifrados en el navegador… es que el navegador los ha descifrado.
Entra el observador del 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,
});
Y funcionó. Hostia puta.
Mensajes descifrados, ahí tirados en el 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>
Ahora viene lo difícil: WhatsApp aleatoriza los nombres de sus clases. Un clásico de Facebook. Si a eso le sumas que la estructura de la interfaz cambia según el idioma, apuntar siempre al elemento correcto es un dolor enorme. Sortearlo exigió abstracciones cuidadosas, coincidencia difusa de elementos y bastantes experimentos.
No voy a entrar en el método exacto, pero basta con decir que lo he resuelto lo suficiente como para que un modelo LLaMA pequeño, ejecutándose en el mismo servidor, extraiga los datos estructurados que hacen falta sin enviar nada a ningún otro sitio. Y esa es la victoria.
Parte 5: Arquitectura de la integración
Con el enfoque del DOM funcionando, monté el flujo completo:
- Lanzar WhatsApp Web en Puppeteer
- Autenticar con código QR
- Esperar a que la sincronización inicial se estabilice
- Enganchar un observador del DOM para extraer los mensajes
- Filtrar los mensajes entrantes
- Pasarlos al analizador de intenciones de calendario
Este es un fragmento de ejemplo para detectar la sincronización:
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);
};
}
});
Vigila las operaciones criptográficas para detectar cuándo se ha asentado el descifrado. Es una forma ingeniosa de saber cuándo ha terminado la sincronización.
Parte 6: La función final de extracción
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);
}
Unas notas más
Esto no es una operación ligera.
Usar Puppeteer para emular un navegador y mantener una sesión de WhatsApp activa por usuario es… valiente. Y probablemente la peor decisión de arquitectura para algo que quieres que se sienta rápido, ágil y de baja latencia. Mantener una instancia completa de Chrome pesa. Conservar el estado del navegador entre ejecuciones es complicado. Y averiguar cómo mantenerlo todo sincronizado, aislado y aun así rápido fue, posiblemente, lo más difícil de todo.
Pero funciona.
Hay un poco de salsa especial que permite a un modelo LLaMA local y pequeño analizar los mensajes y detectar intenciones directamente en el host que ejecuta el navegador. Sin llamadas a APIs externas, sin que los datos de los mensajes salgan de la máquina, y todo en solo unos pocos tramos de 10 segundos. (Ojalá pudiera decir que en tiempo real.)
Pronto los usuarios podrán beneficiarse de esto. Y qué pesadilla fue llegar hasta aquí.
Pero, sinceramente, merece la pena. Hay muchísimo contexto personal enterrado en tus mensajes, y ahora podemos hacer cosas útiles con él. Como… recordar qué te pidió tu pareja que compraras para la cena hace 300 mensajes.
(Spoiler: lo hice de verdad con esto. Me salvó la vida.)
Reflexiones finales
Empecé pensando que descifraría los mensajes de WhatsApp, me rendí, construí un rascador de DOM en su lugar… y luego descifré de verdad el cifrado mientras escribía este post.
Giro de guion.
Aquí está la cuestión: WhatsApp se esfuerza MUCHO en que rascar el DOM sea dificilísimo. Aleatorizan los nombres de las clases, cambian la estructura de los elementos según el idioma y, en general, te amargan la vida si intentas extraer datos de la interfaz de forma fiable. Mi solución con el DOM funcionó, pero siempre iba a ser un juego del gato y el ratón.
En cambio, con el descifrado de verdad extraemos directamente de la fuente de datos. Salida estructurada, análisis fiable, sin tener que rastrear clases CSS de nombre aleatorio ni lidiar con cambios de interfaz. Es una mejora seria para la fiabilidad a largo plazo.
El enfoque de descifrado significa:
- Se acabaron los frágiles selectores CSS que se rompen con cada actualización de WhatsApp
- Datos estructurados en lugar de analizar HTML
- Mejor rendimiento, sin observar el DOM constantemente
- Historial de mensajes completo, no solo lo que se ve en pantalla
¿La moraleja?
A veces la solución “imposible” merece la pena después de todo. El enfoque del DOM me llevó al 80% del camino y me enseñó lo bastante sobre la arquitectura de WhatsApp como para resolver el 20% restante.
Y sí, soltar tacos sigue ayudando.
La implementación completa del descifrado da para otro post, pero de momento, ¡funciona! Eso sí, HKDF y wawc_db_enc pueden irse a la mierda tranquilamente.