WhatsApp Web niet ontsleutelen (en toch winnen)
Op deze pagina
Of: een verhaal over 6.000 vloeken en één per ongeluk behaalde overwinning
Ik heb vanavond ongeveer 6.000 keer “fuck” gezegd.
Meer of minder. Eén keer was het vast van blijdschap, de rest kwam van het reverse engineeren van de datalaag van WhatsApp Web. Dat was niet mijn plan toen ik vanochtend opstond. Maar nieuwsgierigheid is een hel van een drug.
Vanavond hoorde ik voor het eerst dat WhatsApp het Signal Protocol gebruikt.
Gaaf! En ook: fuck.
Even terug naar het begin.
Het oorspronkelijke doel
Ik werkte aan een nieuwe integratie voor Jamie, mijn persoonlijke AI-assistent. Het doel was simpel: WhatsApp-berichten binnenhalen, tijdsaanwijzingen herkennen (“laten we maandag om 3 uur doen”) en die automatisch in je agenda zetten.
Makkelijk, toch?
Hahahahaha.
Deel 1: onwetendheid is een zegen (IndexedDB)
Ik begon in de browser. WhatsApp Web bewaart data in IndexedDB en ik dacht: ik kijk gewoon even binnen.
async function exploreWhatsAppDatabases() {
const databases = await indexedDB.databases();
console.log('Found databases:', databases);
// ['model-storage', 'signal-storage', 'wawc']
}
Tot zover goed.
- model-storage → bevat berichten
- signal-storage → bevat cryptosleutels (oei)
- wawc → metadata
Ik haalde wat voorbeelddata uit model-storage:
{
id: "false_AAAAHHHHHHHHHHHHHHH@g.us_OBFUSCATINGFOROBVIOUSREASONS_00000000000@c.us",
msgRowOpaqueData: {
_data: ArrayBuffer(160),
iv: Uint8Array(16),
_keyId: 1,
_scheme: 1
}
}
Alles is versleuteld.
Elk. Enkel. Bericht.
En niet zomaar versleuteld, maar goed versleuteld. Hoge entropie, wisselende grootte, een consistent schema.
Let op: IndexedDB speelt nog steeds een rol in de uiteindelijke implementatie. Ik gebruik het om de synchronisatiestatus bij te houden, te bevestigen dat het laden van berichten echt klaar is en te voorkomen dat ik met halve data werk. De kunst is te weten wanneer de client helemaal tot rust is gekomen.
Deel 2: laten we het toch met brute kracht proberen
Zoals elk verstandig mens dat lak heeft aan zijn eigen tijd probeerde ik de berichten rechtstreeks te ontsleutelen.
Stap 1: pak de hoofdsleutel
const masterKey = localStorage.getItem('WebEncKeySalt');
// A 172-byte base64 string
Stap 2: gebruik hem in 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);
}
}
Resultaat:
fuck: OperationError: AES-GCM decryption failed
Deel 3: laten we in 2 uur even het Signal Protocol van nul leren (geen probleem)
Na wat graafwerk besefte ik dat WhatsApp het Signal Protocol gebruikt, met Double Ratchet, chain keys, identity keys, prekeys… het hele circus.
De sessiesleutels staan in signal-storage, verdeeld over:
session-storeidentity-storeprekey-storesignal-meta-store
Ik haalde de sessiestructuren uit elkaar:
{
address: "192432234885263@lid.0",
session: {
rootKey: Uint8Array(32),
sendChain: { chainKey: ... },
recvChains: [...]
}
}
In theorie kan dit.
In de praktijk is een werkende Signal Protocol-client bouwen en de sleutelafleiding van WhatsApp nabootsen hetzelfde als end-to-end-encryptie helemaal opnieuw implementeren.
Dus ik verspilde er natuurlijk een uur aan.
Deel 4: wacht even… de DOM weet dingen
Dit was het keerpunt.
Als ik ontsleutelde berichten in de browser kan zien… dan heeft de browser ze ontsleuteld.
Daar komt de DOM-observer:
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,
});
En het werkte. Holy shit.
Ontsleutelde berichten, gewoon in de 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>
Nu komt het lastige: WhatsApp randomiseert de klassenamen. Typisch Facebook. Door de lokalisatieverschillen in de UI-structuur per taal is consistent targeten een gigantische pijn. Het oplossen kostte zorgvuldige abstractie, fuzzy elementmatching en meer dan een paar experimenten.
Ik ga niet in op de precieze aanpak, maar ik heb het genoeg opgelost dat een klein LLaMA-model op dezelfde server de relevante gestructureerde data kan halen zonder iets ergens anders heen te sturen. Dat is de winst.
Deel 5: integratiearchitectuur
Met de DOM-aanpak aan het werk bouwde ik de hele flow:
- Start WhatsApp Web in Puppeteer
- Log in met een QR-code
- Wacht tot de eerste synchronisatie stabiel is
- Hang een DOM-observer op om berichten te halen
- Filter binnenkomende berichten
- Stuur door naar de parser voor agenda-intenties
Hier is een voorbeeld van de detectie van de synchronisatie:
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);
};
}
});
Het kijkt naar cryptobewerkingen om te zien wanneer het ontsleutelen tot rust is gekomen. Een slimme manier om te merken wanneer de synchronisatie klaar is.
Deel 6: de definitieve extractiefunctie
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);
}
Nog een paar opmerkingen
Dit is geen lichte operatie.
Puppeteer gebruiken om een browser na te bootsen en per gebruiker een actieve WhatsApp-sessie te onderhouden is… gedurfd. Het is ook waarschijnlijk de slechtste architectuurkeuze voor iets dat snel, responsief en laag in latency moet aanvoelen. Een complete Chrome-instantie draaiende houden is zwaar. De browserstatus tussen runs bewaren is ingewikkeld. En uitzoeken hoe je alles gesynchroniseerd, geïsoleerd en toch snel houdt was misschien wel het moeilijkste van alles.
Maar het werkt.
Er zit nu een beetje geheime saus in waarmee een klein lokaal LLaMA-model berichten kan parseren en intenties kan herkennen, direct op de host waar de browserinstantie draait. Geen externe API-aanroepen, geen berichtdata die de machine verlaat en alles werkt in maar een paar keer 10 seconden. (Ik wou dat ik realtime kon zeggen.)
Gebruikers kunnen er binnenkort van profiteren. En wat een nachtmerrie was het om hier te komen.
Maar eerlijk: het is het waard. Er zit zoveel persoonlijke context begraven in je berichten en daar kunnen we nu iets zinnigs mee doen. Bijvoorbeeld onthouden wat je partner 300 berichten geleden vroeg om voor het avondeten mee te nemen.
(Spoiler: dat heb ik er echt mee gedaan. Een redder in nood.)
Slotgedachten
Ik begon in de veronderstelling dat ik WhatsApp-berichten zou ontsleutelen, gaf het op, bouwde in plaats daarvan een DOM-scraper… en kraakte de ontsleuteling toen alsnog terwijl ik dit schreef.
Plotwending.
Het zit zo: WhatsApp doet ontzettend veel moeite om DOM-scraping extreem lastig te maken. Ze randomiseren klassenamen, veranderen elementstructuren per lokalisatie en maken je leven in het algemeen zuur als je betrouwbaar data uit de UI wilt halen. Mijn DOM-oplossing werkte, maar het bleef altijd kat-en-muis.
Maar met echte ontsleuteling? Dan haal je data rechtstreeks uit de bron. Gestructureerde uitvoer, betrouwbaar parsen, niet meer zoeken naar willekeurig genoemde CSS-klassen en geen gedoe met UI-wijzigingen. Voor de betrouwbaarheid op lange termijn is dat een serieuze verbetering.
De ontsleutelaanpak betekent:
- Geen breekbare CSS-selectors meer die bij elke WhatsApp-update kapotgaan
- Gestructureerde data in plaats van HTML parsen
- Betere prestaties zonder constante DOM-observatie
- De complete berichtgeschiedenis, niet alleen wat op het scherm staat
De moraal van het verhaal?
Soms is de “onmogelijke” oplossing het najagen toch waard. De DOM-aanpak bracht me voor 80% bij het doel en leerde me genoeg over de architectuur van WhatsApp om de resterende 20% te kraken.
En ja, vloeken helpt nog steeds.
De volledige implementatie van de ontsleuteling is een verhaal voor een andere post, maar voor nu werkt het! Wel zeg ik erbij dat HKDF en wawc_db_enc van mij mogen verdwijnen naar een plek waar de zon niet schijnt.