WhatsApp Analysis
SkillWeb & browsingAnalyze WhatsApp Web data — contacts, groups, social graph, AND decrypt actual message content via live browser interception. Use when: 'WhatsApp contacts', 'WhatsApp groups', 'WhatsApp analysis', 'who do I talk to', 'WhatsApp messages', 'decrypt WhatsApp', 'read WhatsApp messages', 'WhatsApp network', 'social graph', 'inner circle'.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the WhatsApp Analysis skill
What this skill tells your AI
The instructions your AI receives, as published by m13v/ai-browser-profile in whatsapp/SKILL.md and read by ahel’s review.
Two capabilities:
- Live message decryption — intercept
crypto.subtle.decryptvia Playwright to read actual message text (requires open browser session) - Metadata analysis — contacts, groups, social graph from IndexedDB (offline, fast)
Part 1: Live Message Decryption (Playwright)
How It Works
WhatsApp Web encrypts messages in IndexedDB (msgRowOpaqueData) using AES-CBC-128 with keys derived via HKDF-SHA256. The crypto happens on the main thread (not Web Workers). By intercepting crypto.subtle before page JS loads, we capture plaintext output and extractable keys.
Prerequisites
- MCP Playwright browser available
- WhatsApp Web logged in (or QR code scan needed)
Step 1: Navigate to WhatsApp Web
Use browser_navigate to go to https://web.whatsapp.com
Wait for the page to load. If QR code appears, user must scan it.
Use browser_snapshot to verify the chat list is visible.
Step 2: Install the crypto interceptor
Use browser_run_code with this exact code to install the interceptor via addInitScript (runs before any page JS on reload):
async (page) => {
await page.addInitScript(() => {
const scope = (typeof globalThis !== 'undefined') ? globalThis : self;
scope.__waCaptured = { decrypt: [], deriveKey: [], importKey: [] };
const origImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = function(format, keyData, algorithm, extractable, keyUsages) {
return origImportKey(format, keyData, algorithm, true, keyUsages);
};
const origGenerateKey = crypto.subtle.generateKey.bind(crypto.subtle);
crypto.subtle.generateKey = function(algorithm, extractable, keyUsages) {
return origGenerateKey(algorithm, true, keyUsages);
};
const origDeriveKey = crypto.subtle.deriveKey.bind(crypto.subtle);
crypto.subtle.deriveKey = async function(algorithm, baseKey, derivedKeyType, extractable, keyUsages) {
const result = await origDeriveKey(algorithm, baseKey, derivedKeyType, true, keyUsages);
try {
const raw = await crypto.subtle.exportKey('raw', result);
const b64 = btoa(String.fromCharCode(...new Uint8Array(raw)));
scope.__waCaptured.deriveKey.push({
ts: Date.now(),
alg: algorithm.name,
saltLen: algorithm.salt ? algorithm.salt.byteLength : 0,
derivedAlg: derivedKeyType.name,
derivedLen: derivedKeyType.length,
keyB64: b64,
usages: keyUsages
});
} catch(e) {}
return result;
};
const origDecrypt = crypto.subtle.decrypt.bind(crypto.subtle);
crypto.subtle.decrypt = async function(algorithm, key, data) {
const result = await origDecrypt(algorithm, key, data);
let keyB64 = null;
try {
const raw = await crypto.subtle.exportKey('raw', key);
keyB64 = btoa(String.fromCharCode(...new Uint8Array(raw)));
} catch(e) { keyB64 = 'export-failed'; }
const entry = {
ts: Date.now(),
alg: algorithm.name,
key: keyB64,
inSize: data.byteLength,
outSize: result.byteLength
};
if (result.byteLength > 0 && result.byteLength < 500000) {
const slice = new Uint8Array(result.slice(0, Math.min(1500, result.byteLength)));
entry.outB64 = btoa(String.fromCharCode(...slice));
}
scope.__waCaptured.decrypt.push(entry);
return result;
};
});
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(8000);
return 'Interceptor installed and page reloaded. Decryptions are being captured.';
}
Step 3: Collect captured decryptions
Wait 10-15 seconds after reload, then extract with browser_run_code:
async (page) => {
const data = await page.evaluate(() => {
const c = (globalThis || self).__waCaptured;
if (!c) return JSON.stringify({error: 'no captures'});
return JSON.stringify({
decryptCount: c.decrypt.length,
deriveKeyCount: c.deriveKey.length,
derivedKeys: c.deriveKey,
sample: c.decrypt.slice(0, 5)
});
});
return data;
}
Step 4: Extract and parse message text
Decrypted output is protobuf. Extract text with browser_run_code:
async (page) => {
const result = await page.evaluate(() => {
const c = (globalThis || self).__waCaptured;
if (!c) return JSON.stringify({error: 'no captures'});
function readVarint(bytes, pos) {
let result = 0, shift = 0;
while (pos < bytes.length) {
const byte = bytes[pos];
result |= (byte & 0x7f) << shift;
shift += 7; pos++;
if (!(byte & 0x80)) return [result, pos];
}
return [null, pos];
}
function parseProtobufText(bytes) {
if (!bytes || bytes[0] !== 0x0a) return null;
let pos = 1;
let [outerLen, p1] = readVarint(bytes, pos);
if (outerLen === null || p1 >= bytes.length) return null;
pos = p1;
if (bytes[pos] !== 0x0a) return null;
pos++;
let [textLen, p2] = readVarint(bytes, pos);
if (textLen === null || textLen <= 0 || p2 + textLen > bytes.length) return null;
pos = p2;
try {
return new TextDecoder('utf-8').decode(bytes.slice(pos, pos + textLen));
} catch(e) { return null; }
}
function extractSender(bytes) {
try {
const str = Array.from(bytes).map(b => String.fromCharCode(b)).join('');
const match = str.match(/(\d+@(?:s\.whatsapp\.net|lid|g\.us))/);
return match ? match[1] : null;
} catch(e) { return null; }
}
const messages = [];
for (const d of c.decrypt) {
if (!d.outB64 || d.alg !== 'AES-CBC') continue;
try {
const bytes = Uint8Array.from(atob(d.outB64), c => c.charCodeAt(0));
const text = parseProtobufText(bytes);
if (text && text.length > 0) {
messages.push({
text: text,
sender: extractSender(bytes),
ts: d.ts
});
}
} catch(e) {}
}
return JSON.stringify({
totalDecrypts: c.decrypt.length,
textMessages: messages.length,
messages: messages
});
});
return result;
}
Step 5: Navigate to more chats for additional messages
WhatsApp only decrypts messages for loaded chats. To capture more:
Use browser_snapshot to see the chat list.
Click on different chats using browser_click with the ref for each chat.
Wait 3-5 seconds between clicks for decryption to complete.
Re-run Step 4 to collect newly decrypted messages.
Step 6: Store messages in memories.db
import sys, os
sys.path.insert(0, os.path.expanduser("~/ai-browser-profile"))
from ai_browser_profile import MemoryDB
from ai_browser_profile.ingestors.messages import ingest_messages, message_stats
mem = MemoryDB(os.path.expanduser("~/ai-browser-profile/memories.db"))
# messages = the parsed messages list from Step 4
inserted = ingest_messages(mem, messages)
print(f"Inserted {inserted} new messages")
stats = message_stats(mem)
print(f"Total stored: {stats['total_messages']}")
mem.close()
Step 7: Analyze messages and write relationship memories
from ai_browser_profile.ingestors.messages import get_messages
mem = MemoryDB(os.path.expanduser("~/ai-browser-profile/memories.db"))
all_msgs = get_messages(mem, limit=1000)
# After analyzing, write relationship/interest memories:
mem.upsert("relationship:ContactName", "description of relationship",
["contact", "relationship"], 0.8, "whatsapp:messages")
mem.conn.commit()
mem.close()
Part 2: Metadata Analysis (Offline from IndexedDB)
Prerequisites
Run the memory extraction first to populate contacts in memories.db:
cd ~/ai-browser-profile
source .venv/bin/activate
python extract.py
For deeper metadata analysis, read IndexedDB directly:
import shutil, tempfile, json
from pathlib import Path
from ccl_chromium_reader import ccl_chromium_indexeddb
APP_SUPPORT = Path.home() / "Library" / "Application Support"
arc_idb = APP_SUPPORT / "Arc" / "User Data" / "Default" / "IndexedDB"
for db_dir in arc_idb.glob("*whatsapp*_0.indexeddb.leveldb"):
tmp = Path(tempfile.mkdtemp())
shutil.copytree(db_dir, tmp / db_dir.name)
blob_dir = db_dir.parent / db_dir.name.replace(".leveldb", ".blob")
tmp_blob = None
if blob_dir.exists():
tmp_blob = Path(tempfile.mkdtemp())
shutil.copytree(blob_dir, tmp_blob / blob_dir.name)
wrapper = ccl_chromium_indexeddb.WrappedIndexDB(
str(tmp / db_dir.name),
str(tmp_blob / blob_dir.name) if tmp_blob else None,
)
# Now iterate stores...
Data Available
WhatsApp Web stores 51 IndexedDB object stores. Message bodies are encrypted (Signal protocol), but all metadata is plaintext:
| Store | Records | What's in it |
|---|---|---|
| contact | 1000 | Phone numbers, names, isAddressBook, isBusiness |
| chat | 1000 | Chat IDs, last message timestamps, unread counts |
| group-metadata | 400+ | Group subjects, creation dates, owner phone |
| participant | 400+ | Group member phone lists |
| message | 1000 | Message type, from/to/author, timestamps (NOT body) |
| reactions | 900+ | Emoji reactions with sender, timestamp |
Inner Circle Analysis
Count shared group membership to find closest contacts:
your_groups = set()
person_groups = {}
for participant_record in participants:
gid = record['groupId']
for p in record['participants']:
phone = p.split('@')[0]
resolved = lid_to_phone.get(phone, phone)
if resolved == YOUR_NUMBER:
your_groups.add(gid)
person_groups.setdefault(resolved, set()).add(gid)
shared = {p: len(gs & your_groups) for p, gs in person_groups.items() if p != YOUR_NUMBER}
top_connections = sorted(shared.items(), key=lambda x: -x[1])
Known Limitations
- Session-specific keys: HKDF-derived keys change per browser session
- Offline decryption: Does not work across sessions
- Record cap: 1000 per IndexedDB store
- Contact names: Only available for address book contacts
- Timestamps: Unix epoch seconds — convert with
datetime.fromtimestamp(t, tz=timezone.utc)
Signals
- GitHub stars
- 53
- Forks
- 5
- Last commit
- Aug 2026
ahel review
S4info
community integration — published by m13v, not whatsapp
Automated review, not a security audit. Ruleset v1.
Advanced
- Catalog kind
- skill
- Gateway key
whatsapp-analysis- Source
- github.com/m13v/ai-browser-profile