How to Not Decrypt WhatsApp Web (But Still Win)

Table of Contents
Or: A tale of 6,000 f-bombs and one accidental victory
I said “fuck” about 6,000 times tonight.
Give or take. One of them was probably celebratory, the rest were from trying to reverse engineer WhatsApp Web’s data storage. I wasn’t planning on doing that when I woke up. But curiosity is a hell of a drug.
Tonight was the first time I learned that WhatsApp uses the Signal Protocol.
Cool! Also: fuck.
Let’s rewind.
The Original Goal #
I was working on a new integration for Jamie — my AI personal assistant. The goal was simple: pull in WhatsApp messages, detect time-based cues (“let’s do Monday at 3pm”), and automatically add them to your calendar.
Easy, right?
Hahahahaha.
Part 1: Ignorance is Bliss (IndexedDB) #
I started with the browser. WhatsApp Web stores data in IndexedDB, and I figured I’d just… peek inside.
async function exploreWhatsAppDatabases() {
const databases = await indexedDB.databases();
console.log('Found databases:', databases);
// ['model-storage', 'signal-storage', 'wawc']
}
So far, so good.
- model-storage → holds messages
- signal-storage → holds crypto keys (oh no)
- wawc → metadata
I pulled out some sample data from model-storage
:
{
id: "false_AAAAHHHHHHHHHHHHHHH@g.us_OBFUSCATINGFOROBVIOUSREASONS_00000000000@c.us",
msgRowOpaqueData: {
_data: ArrayBuffer(160),
iv: Uint8Array(16),
_keyId: 1,
_scheme: 1
}
}
Everything is encrypted.
Every. Single. Message.
And not just encrypted—properly encrypted. High entropy, dynamic size, consistent scheme.
Note: IndexedDB still plays a role in the final implementation — I use it to track sync state, confirm when message loading has actually completed, and avoid working with partial data. The key is figuring out when the client has fully stabilised.
Part 2: Let’s Try to Brute Force It Anyway #
Like any sane person with no regard for their time, I tried to decrypt the messages directly.
Step 1: Grab the Master Key #
const masterKey = localStorage.getItem('WebEncKeySalt');
// A 172-byte base64 string
Step 2: Use It 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);
}
}
Result:
fuck: OperationError: AES-GCM decryption failed
Part 3: Let’s Learn Signal Protocol from Scratch in 2 Hours (No Big Deal) #
After some digging, I realised WhatsApp’s using the Signal Protocol, complete with Double Ratchet, chain keys, identity keys, prekeys… the whole shebang.
Session keys are stored in signal-storage
, split across:
session-store
identity-store
prekey-store
signal-meta-store
I reverse engineered the session structures:
{
address: "192432234885263@lid.0",
session: {
rootKey: Uint8Array(32),
sendChain: { chainKey: ... },
recvChains: [...]
}
}
In theory, this is doable.
In practice, building a working Signal Protocol client and replicating WhatsApp’s key derivation is like re-implementing end-to-end encryption from scratch.
So naturally, I wasted an hour trying.
Part 4: Wait a Minute… The DOM Knows Things #
This was the turning point.
If I can see decrypted messages in the browser… then the browser decrypted them.
Cue the 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
});
And it worked. Holy shit.
Decrypted messages, just sitting there in the 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>
Now — here’s the tricky bit: WhatsApp randomises its class names. Classic Facebook. Combined with localisation differences in UI structure across languages, targeting consistently is a giant pain. Working around it took some careful abstraction, fuzzy element matching, and more than a few experiments.
I won’t go into the exact approach, but suffice it to say — I’ve resolved it enough that a small LLaMA model running on the same server can extract the relevant structured data without transmitting anything elsewhere. And that’s the win.
Part 5: Integration Architecture #
With the DOM approach working, I built out the full flow:
- Launch WhatsApp Web in Puppeteer
- Authenticate with QR code
- Wait for initial sync to stabilise
- Hook a DOM observer to extract messages
- Filter incoming messages
- Pipe to calendar intent parser
Here’s a sample sync detection snippet:
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);
};
}
});
It watches crypto operations to detect when decryption has settled — a clever way to tell when sync has finished.
Part 6: The Final Extraction Function #
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);
}
A Few More Notes #
This isn’t a lightweight operation.
Using Puppeteer to emulate a browser and maintain an active WhatsApp session per user is… bold. It’s also probably the worst architectural choice for something you want to feel fast, responsive and low-latency. Maintaining a full Chrome instance is heavy. Preserving browser state between runs is complex. And figuring out how to keep everything synchronised, isolated and still performant was arguably the hardest part of all this.
But it works.
There’s a bit of special sauce in there now that lets a small local LLaMA model do message parsing and intent detection directly on the host running the browser instance. No external API calls, no message data leaving the box and it all works in only a few 10s of seconds. (I wish I could say realtime)
Users will be able to benefit from this soon. And what a nightmare it was to get here.
But honestly — the payoff is worth it. There’s so much personal context buried in your messages, and we can do meaningful things with it now. Like… remembering what your partner asked you to pick up for dinner 300 messages ago.
(Spoiler: I actually did this with it. Life saver.)
Final Thoughts #
I started this thinking I’d decrypt WhatsApp messages, gave up, built a DOM scraper instead… and then actually cracked the decryption while writing this post.
Plot twist.
Here’s the thing: WhatsApp does a LOT of work to make DOM scraping extremely difficult. They randomise class names, change element structures across localisations, and generally make your life miserable if you’re trying to reliably extract data from the UI. My DOM solution worked, but it was always going to be a game of cat and mouse.
But with proper decryption? We’re extracting directly from the data source. Structured output, reliable parsing, no more hunting for randomly-named CSS classes or dealing with UI changes. It’s a serious improvement for ongoing reliability.
The decryption approach means:
- No more fragile CSS selectors that break with every WhatsApp update
- Structured data instead of parsing HTML
- Better performance without constant DOM observation
- Complete message history not just what’s rendered on screen
Moral of the story?
Sometimes the “impossible” solution is worth pursuing after all. The DOM approach got me 80% there and taught me enough about WhatsApp’s architecture to crack the remaining 20%.
And yes, swearing still helps.
The full decryption implementation is a story for another post, but for now — it works! But I will say HKDF & wawc_db_enc can quietly go and die somewhere.