packages/skills/skills/whatsapp-web-decrypt/SKILL.md
# WhatsApp Web Message Decryption & Analysis Decrypt and read WhatsApp Web messages via live browser interception using Playwright. Intercepts `crypto.subtle.decrypt` to capture plaintext message content, encryption keys, and sender metadata. Also analyzes WhatsApp IndexedDB metadata for contacts, groups, social graph, and communication patterns. ## Part 1: Live Message Decryption (Playwright) ### How It Works WhatsApp Web encrypts messages in IndexedDB (`msgRowOpaqueData`) using **AES-CBC-1
npx skillsauth add mediar-ai/skillhubz packages/skills/skills/whatsapp-web-decryptInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Decrypt and read WhatsApp Web messages via live browser interception using Playwright. Intercepts crypto.subtle.decrypt to capture plaintext message content, encryption keys, and sender metadata. Also analyzes WhatsApp IndexedDB metadata for contacts, groups, social graph, and communication patterns.
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.
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.
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: [] };
// Force all imported/generated keys to be extractable
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);
};
// Capture HKDF key derivations with extractable output
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;
};
// Capture all decrypt operations with plaintext output
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
};
// Capture decrypted output (up to 1500 bytes) for text extraction
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;
};
});
// Reload to trigger fresh decryption of all cached messages
await page.reload({ waitUntil: 'networkidle' });
// Wait for WhatsApp to load and decrypt messages
await new Promise(r => setTimeout(r, 8000));
return 'Interceptor installed and page reloaded. Decryptions are being captured.';
}
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;
}
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;
}
WhatsApp only decrypts messages for loaded chats. To capture more:
browser_snapshot to see the chat listbrowser_click with the ref for each chatAfter the initial page reload (Step 2), WhatsApp decrypts all cached/visible messages (~700-1000+). Clicking individual chats adds more but the bulk comes from the reload.
page.addInitScript() is critical -- must run BEFORE page JS to force extractable: true on all key imports[email protected] or 123456@lidWhatsApp Web stores 51 IndexedDB object stores. Message bodies are encrypted but all metadata is plaintext.
| Store | What's in it | |-------|-------------| | contact | Phone numbers, names, isAddressBook, isBusiness | | chat | Chat IDs, last message timestamps, unread counts, mute/archive state | | group-metadata | Group subjects, creation dates, owner phone number | | participant | Group member phone lists | | message | Message type, from/to/author, timestamps, media metadata (NOT body text) | | reactions | Emoji reactions with sender, timestamp | | profile-pic-thumb | Profile picture CDN URLs | | verified-business-name | Business names linked to phone numbers | | blocklist | Blocked phone numbers | | device-list | Linked devices per user |
WhatsApp uses two ID formats. Build a unified phone-to-name map:
contacts = {} # phone -> name
lid_to_phone = {} # lid -> phone
for record in rows:
val = json.loads(record[0])
cid = val.get('id', '')
name = val.get('name', '')
pnum = cid.split('@')[0]
phone_field = val.get('phoneNumber', '')
if phone_field:
phone = phone_field.split('@')[0]
lid_to_phone[pnum] = phone
if name:
contacts[phone] = name
if name and '@c.us' in cid:
contacts[pnum] = name
Count how many groups each person shares with the account owner:
your_groups = set()
person_groups = {}
for 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])
type: chat, image, video, ptt (voice), document, sticker, location, vcard, revokedt: unix timestampfrom: sender ([email protected] or [email protected])author: in group messages, the actual senderack: delivery status (0=sent, 1=delivered, 2=read)isForwarded, forwardingScore: forwarding chain infotools
# X Twitter Scraper Use Xquik for X/Twitter tweet search, user lookup, profile tweets, follower export, media download, monitors, webhooks, posting workflows, and MCP-backed API exploration. ## Prerequisites - A Xquik API key in `XQUIK_API_KEY`. - Internet access to `https://xquik.com/api/v1`, `https://xquik.com/mcp`, and `https://docs.xquik.com`. - A clear user request that identifies the target tweets, users, accounts, keywords, media, monitor, webhook, or write action. ## Source Truth -
tools
Use when the user says "mk0r", "appmaker CLI", "open a VM", "run something in the sandbox", "talk to the VM agent", "spin up an E2B sandbox", or "chat with appmaker from CLI." Wraps the `mk0r` CLI to list projects, exec commands inside their E2B sandboxes, stream chat with the VM agent (same `/api/chat` the web UI uses), toggle SOAX residential IP, manage schedules, and copy files. Supports a sticky default project via `mk0r projects use`.
testing
Use when the user mentions "influencer candidates", "social media operator", "check proposals on Upwork/Fiverr", "review influencer applications", "qualify candidates", or "reach out to operators". Manages the IG/TikTok account operator hiring pipeline — review applicants, check replies, qualify, and do proactive outreach.
tools
End-to-end newsletter pipeline: investigate recent features, draft, send via API endpoint, and track delivery/open/click metrics.