Comment ne pas déchiffrer WhatsApp Web (et gagner quand même)
Sur cette page
Ou : l’histoire de 6 000 jurons et d’une victoire par accident
J’ai dit « putain » environ 6 000 fois ce soir.
À peu de chose près. Un seul était probablement de joie, les autres venaient de ma tentative de rétro-ingénierie du stockage de WhatsApp Web. Je ne comptais pas faire ça en me levant ce matin. Mais la curiosité, c’est une sacrée drogue.
Ce soir, j’ai appris que WhatsApp utilise le protocole Signal.
Génial ! Et aussi : putain.
Reprenons depuis le début.
L’objectif de départ
Je bossais sur une nouvelle intégration pour Jamie, mon assistant personnel IA. L’idée était simple : récupérer les messages WhatsApp, repérer les indices temporels (« on fait lundi à 3 h de l’après-midi ») et les ajouter automatiquement à ton agenda.
Facile, non ?
Hahahahaha.
Partie 1 : Heureux les ignorants (IndexedDB)
Je suis parti du navigateur. WhatsApp Web stocke ses données dans IndexedDB, et je me suis dit que j’allais juste… jeter un œil.
async function exploreWhatsAppDatabases() {
const databases = await indexedDB.databases();
console.log('Found databases:', databases);
// ['model-storage', 'signal-storage', 'wawc']
}
Jusque-là, tout va bien.
- model-storage → contient les messages
- signal-storage → contient les clés de chiffrement (aïe)
- wawc → métadonnées
J’ai extrait un échantillon de model-storage :
{
id: "false_AAAAHHHHHHHHHHHHHHH@g.us_OBFUSCATINGFOROBVIOUSREASONS_00000000000@c.us",
msgRowOpaqueData: {
_data: ArrayBuffer(160),
iv: Uint8Array(16),
_keyId: 1,
_scheme: 1
}
}
Tout est chiffré.
Chaque. Putain. De. Message.
Et pas juste chiffré : bien chiffré. Entropie élevée, taille dynamique, schéma cohérent.
Note : IndexedDB joue encore un rôle dans l’implémentation finale. Je m’en sers pour suivre l’état de la synchronisation, confirmer que le chargement des messages est vraiment terminé et éviter de travailler sur des données partielles. Tout l’enjeu est de savoir quand le client s’est complètement stabilisé.
Partie 2 : Essayons quand même la force brute
Comme n’importe quelle personne saine d’esprit qui se fiche de son temps, j’ai tenté de déchiffrer les messages directement.
Étape 1 : récupérer la clé maîtresse
const masterKey = localStorage.getItem('WebEncKeySalt');
// A 172-byte base64 string
Étape 2 : l’utiliser avec 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);
}
}
Résultat :
fuck: OperationError: AES-GCM decryption failed
Partie 3 : apprenons le protocole Signal de zéro en 2 heures (rien de grave)
Après quelques recherches, j’ai compris que WhatsApp utilise le protocole Signal, avec Double Ratchet, chain keys, identity keys, prekeys… tout le bazar.
Les clés de session sont stockées dans signal-storage, réparties entre :
session-storeidentity-storeprekey-storesignal-meta-store
J’ai rétro-ingénieré la structure des sessions :
{
address: "192432234885263@lid.0",
session: {
rootKey: Uint8Array(32),
sendChain: { chainKey: ... },
recvChains: [...]
}
}
En théorie, c’est faisable.
En pratique, construire un client Signal qui marche et reproduire la dérivation de clés de WhatsApp, c’est réimplémenter le chiffrement de bout en bout depuis zéro.
Alors bien sûr, j’ai perdu une heure à essayer.
Partie 4 : une minute… Le DOM sait des choses
C’était le tournant.
Si je vois les messages déchiffrés dans le navigateur… c’est que le navigateur les a déchiffrés.
Entrée de l’observateur 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,
});
Et ça a marché. Putain de merde.
Des messages déchiffrés, là, posés dans le 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>
Maintenant, voilà le point délicat : WhatsApp rend ses noms de classes aléatoires. Du Facebook tout craché. Ajoute à ça les différences de structure de l’interface selon les langues, et cibler les éléments de façon fiable devient un enfer. Le contourner a demandé une abstraction soignée, de la correspondance floue d’éléments et pas mal d’expériences.
Je ne détaillerai pas l’approche exacte, mais je l’ai assez bien résolu pour qu’un petit modèle LLaMA, qui tourne sur le même serveur, extraie les données structurées utiles sans rien transmettre ailleurs. Et c’est ça, la victoire.
Partie 5 : l’architecture de l’intégration
Avec l’approche DOM qui marchait, j’ai construit tout le flux :
- Lancer WhatsApp Web dans Puppeteer
- S’authentifier avec le QR code
- Attendre que la synchronisation initiale se stabilise
- Brancher un observateur DOM pour extraire les messages
- Filtrer les messages entrants
- Envoyer le tout à l’analyseur d’intentions d’agenda
Voici un exemple de détection de synchronisation :
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);
};
}
});
Il surveille les opérations cryptographiques pour détecter le moment où le déchiffrement s’est calmé. Une façon astucieuse de savoir que la synchronisation est terminée.
Partie 6 : la fonction d’extraction finale
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);
}
Quelques remarques de plus
Ce n’est pas une opération légère.
Utiliser Puppeteer pour émuler un navigateur et garder une session WhatsApp active par utilisateur, c’est… audacieux. C’est aussi sans doute le pire choix d’architecture pour quelque chose qui doit paraître rapide, réactif et peu latent. Faire tourner une instance complète de Chrome est lourd. Conserver l’état du navigateur d’une exécution à l’autre est complexe. Et trouver comment garder le tout synchronisé, isolé et encore performant a sans doute été le plus dur.
Mais ça marche.
Il y a maintenant un petit ingrédient secret qui laisse un petit modèle LLaMA local analyser les messages et détecter les intentions directement sur l’hôte qui fait tourner le navigateur. Aucun appel d’API externe, aucune donnée de message qui quitte la machine, et tout ça prend quelques 10 secondes seulement. (J’aimerais pouvoir dire en temps réel.)
Les utilisateurs pourront bientôt en profiter. Et quel cauchemar pour en arriver là.
Mais franchement, ça vaut le coup. Il y a énormément de contexte personnel enfoui dans tes messages, et on peut maintenant en faire des choses utiles. Par exemple… te rappeler ce que ton ou ta partenaire t’a demandé de rapporter pour le dîner il y a 300 messages.
(Spoiler : je l’ai vraiment fait avec. Ça m’a sauvé la mise.)
Pour finir
Je suis parti en pensant déchiffrer les messages WhatsApp, j’ai abandonné, j’ai construit un scraper DOM à la place… et j’ai fini par craquer le déchiffrement en écrivant ce billet.
Coup de théâtre.
Voilà le truc : WhatsApp fait ÉNORMÉMENT d’efforts pour rendre le scraping du DOM très difficile. Ils rendent les noms de classes aléatoires, changent la structure des éléments selon les langues et te pourrissent la vie dès que tu veux extraire des données de l’interface de façon fiable. Ma solution DOM marchait, mais ce serait toujours un jeu du chat et de la souris.
Avec un vrai déchiffrement, en revanche, on extrait directement à la source. Une sortie structurée, un parsing fiable, plus de chasse aux classes CSS au nom aléatoire ni de changements d’interface à encaisser. C’est un vrai progrès pour la fiabilité dans la durée.
L’approche par déchiffrement veut dire :
- Fini les sélecteurs CSS fragiles qui cassent à chaque mise à jour de WhatsApp
- Des données structurées au lieu de parser du HTML
- De meilleures performances, sans observation constante du DOM
- L’historique complet des messages, pas seulement ce qui s’affiche à l’écran
La morale de l’histoire ?
Parfois, la solution « impossible » vaut quand même le coup d’être poursuivie. L’approche DOM m’a fait faire 80 % du chemin et m’a appris assez sur l’architecture de WhatsApp pour venir à bout des 20 % restants.
Et oui, jurer aide toujours.
L’implémentation complète du déchiffrement, c’est une histoire pour un autre billet, mais pour l’instant, ça marche ! Je dirai juste que HKDF et wawc_db_enc peuvent aller crever quelque part.