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
Use when the user wants to manage Valet agents, channels, connectors, organizations, or environment variables (secrets and plain config) via the valet CLI. Handles creation, deployment, linking, teardown, and all multi-step workflows. Also use when asked to "create an agent", "deploy an agent", "design an agent", "build me an agent that...", "create a connector", "set up a webhook", or anything involving the Valet platform or any request to create and deploy AI agents. Also use when asked to "learn from this session", "capture this workflow", "save this as an agent", "make this repeatable", or when writing SOUL.md files.
tools
Publish files, folders, and artifacts to the web. Static hosting for HTML sites, images, PDFs, reports, dashboards, and any file type. Use when asked to publish, host, upload, serve, or share work at a live URL. Also use to propose a rendered page when a report, comparison, chart, design document, or status page would work better than terminal text, but do not create or update a remote site until the user asks or agrees. Account publishing gives a permanent, private-by-default URL visible to org members; --anonymous gives a temporary public URL with no account. Use the valet CLI when available and its MCP server when the CLI cannot run. For deploying an AI agent rather than static files, use the `valet` skill instead.
testing
# Faceless.so Turn a script, prompt, Reddit post, or blog into a Remotion short with TTS, captions, and B-roll, then auto-post to YouTube, TikTok, Instagram, X, Facebook, LinkedIn, and Threads. ## Prerequisites - A Faceless.so account (from $24/mo) at https://faceless.so - Source material: script, prompt, Reddit URL, or blog URL - Destination social accounts to auto-post (YouTube, TikTok, Instagram, X, Facebook, LinkedIn, Threads) ## Instructions 1. Open https://faceless.so and start a new
testing
# BIMI SVG Tiny P/S Corpus Validator Use the public makeBIMI SVG Tiny P/S Test Corpus to evaluate an SVG against its evidence-bound fixture rules and to report the result clearly. ## Inputs Accept either an SVG file, an SVG URL, or raw SVG markup. If the source cannot be retrieved or parsed as XML, stop and report that limitation. ## Authoritative corpus 1. Retrieve the current manifest from `https://makebimi.com/public/test-corpus/v1/manifest.json`. 2. Record `schema_version`, `corpus_vers