Build notes · WhatsApp Media Decryption & CAS Streaming Engine
The WhatsApp media pipeline that ran out of memory: Native HKDF Decryption and CAS Streaming
Native HKDF-SHA256 Decryption, Content-Addressable Storage, and RFC 7233 Byte-Range Streaming
Status: Resolved. This postmortem is a historical log. The issue discussed below has been fully fixed.
The Klaros desktop app reads personal WhatsApp numbers through WhatsApp Web. Until this change, every photo, voice note and PDF in a live chat was pulled out of the browser page as a base64 string, one at a time, and kept in memory. Here is how we replaced that with direct HKDF decryption from WhatsApp’s media servers, a content-addressed disk cache, and HTTP byte-range streaming.
The Bottleneck: whatsapp-web.js hands media back from the browser page as a base64 string over the Chrome DevTools Protocol. Base64 is a third larger than the file, every download went through one serial queue, and the result was held in memory and served whole, so audio and video could not seek.
The Fix: Download the encrypted file straight from mmg.whatsapp.net, derive its keys with HKDF-SHA256, verify its MAC, decrypt it with AES-256-CBC in Node, store it once on disk under the SHA-256 of its bytes, and serve it with HTTP 206 byte ranges. Anything that fails on that path falls back to WhatsApp Web’s own download.
Scope: this is the desktop app’s personal-number path. Business numbers on the Cloud API receive media through Meta’s Media API, which is a different pipeline. We have not published latency or memory measurements for this change.
1. Native HKDF Cryptography: Eliminating Chrome Tab Freezes & Memory Bloat
When a customer sends a voice note or image on WhatsApp, the media binary is not stored on signaling servers. Instead, it is uploaded to WhatsApp CDN nodes at mmg.whatsapp.net. The chat message payload contains only metadata: the encrypted URL, file SHA256 hashes, and a 32-byte mediaKey.
To decipher the payload natively without running headless browser scrapers, the 32-byte mediaKey must be expanded using HKDF-SHA256 (RFC 5869) using a 32-byte zero salt and type-specific UTF-8 info string labels:
- Image:
"WhatsApp Image Keys" - Video:
"WhatsApp Video Keys" - Audio / Voice Note (PTT):
"WhatsApp Audio Keys" - Document:
"WhatsApp Document Keys"
The HKDF derivation produces 112 bytes of pseudo-random key material, sliced into four functional keys:
| Key Segment | Byte Range | Function |
|---|---|---|
| Initialization Vector (IV) | Bytes 0 - 15 (16B) | AES-256-CBC initial cipher state |
| Cipher Key | Bytes 16 - 47 (32B) | AES-256-CBC decryption key |
| MAC Key | Bytes 48 - 79 (32B) | HMAC-SHA256 payload integrity validation |
| Ref Key | Bytes 80 - 111 (32B) | Reference validation key |
// src/local-modules/wa-media-decryptor.mjs
import crypto from 'node:crypto';
export function deriveMediaKeys(mediaKey, type = 'image') {
const infoStr = INFO_MAP[type] || INFO_MAP.image;
const salt = Buffer.alloc(32, 0);
const hkdfOutput = crypto.hkdfSync('sha256', mediaKey, salt, Buffer.from(infoStr, 'utf8'), 112);
return {
iv: Buffer.from(hkdfOutput.slice(0, 16)),
cipherKey: Buffer.from(hkdfOutput.slice(16, 48)),
macKey: Buffer.from(hkdfOutput.slice(48, 80)),
refKey: Buffer.from(hkdfOutput.slice(80, 112)),
};
}
export function decryptMediaBuffer(encryptedBlob, mediaKey, type = 'image') {
const ciphertext = encryptedBlob.subarray(0, encryptedBlob.length - 10);
const macTrailer = encryptedBlob.subarray(encryptedBlob.length - 10);
const { iv, cipherKey, macKey } = deriveMediaKeys(mediaKey, type);
// Validate 10-byte truncated HMAC-SHA256 integrity
const hmac = crypto.createHmac('sha256', macKey);
hmac.update(iv);
hmac.update(ciphertext);
const computedMac = hmac.digest().subarray(0, 10);
if (!crypto.timingSafeEqual(computedMac, macTrailer)) {
throw new Error('HMAC verification failed: corrupted payload');
}
const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
2. Content-Addressable Storage (CAS) & Reserved Image Space: Stopping Layout Jitter
Decrypted media is stored content-addressed: the file is named by the SHA-256 of its bytes (blobs/<sha256>.bin), so the same photo forwarded into ten chats is stored once. Each message gets a small record (keys/<sha256 of session and message id>.json) pointing at its blob and holding its MIME type, file name and dimensions. Both are written to a .tmp file and renamed into place, so a reader never sees half a file, and Windows refusing to replace a file that is being streamed is harmless because the content is identical.
The cache lives in the per-user data folder, capped at 5 GB. Reads refresh a blob’s timestamp and the least recently used blobs are evicted first, in a throttled background pass rather than on the request.
When WhatsApp’s message data includes an image’s width and height, the inbox sets the container’s aspect-ratio before the image loads, so the thread does not jump as photos arrive. When it does not, the container falls back to a default ratio.
3. RFC 7233 HTTP 206 Streaming: Eliminating Voice Note Freezes & 4-Second Audio Lag
HTML5 <audio> and <video> players require HTTP 206 Partial Content range responses to seek, scrub, and stream audio without downloading full 50MB files into memory. Klaros implements full RFC 7233 Range parsing supporting bytes=100-499, open-ended bytes=500-, and trailing suffix range requests (bytes=-500 for MP4/MOOV atom seeking).
// src/routes/whatsapp-chats.mjs
const range = parseRangeHeader(req.headers.range, fileSize);
if (range) {
res.status(206);
res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${fileSize}`);
res.setHeader('Content-Length', range.chunkSize);
const fileStream = fs.createReadStream(filePath, { start: range.start, end: range.end });
req.on('close', () => fileStream.destroy()); // Destroy stream if user cancels request mid-scroll
fileStream.pipe(res);
}
4. When the Fast Path Fails: MAC Checks, Fallbacks and a Cache That Cannot Write
The direct path refuses rather than guesses. A file whose 10-byte HMAC does not verify is rejected outright: decrypting it anyway would put a corrupted or tampered file into a cache that serves it for days. The download is only attempted over HTTPS to whatsapp.net hosts, is capped in size while it streams, and follows no redirects.
Every failure on that path, a MAC mismatch, a timeout, an expired URL, falls back to WhatsApp Web’s own downloadMedia(), which is slower but authoritative. And if the disk cache cannot be written, the file is served from memory instead of failing the request: a cache is an optimisation, never a reason not to show the photo.
Cached media is keyed by session as well as message, because one desktop install can hold several WhatsApp profiles, and it is served with Cache-Control: private. It is private chat content.
Run your WhatsApp inbox on infrastructure you own
Deploy Klaros to your own Cloudflare account with zero per-message markup.
Questions people ask about WhatsApp media decryption, CAS storage, and RFC 7233 streaming
How does WhatsApp media decryption work under the hood?
WhatsApp media decryption requires fetching an encrypted binary blob from mmg.whatsapp.net and expanding its 32-byte mediaKey using HKDF-SHA256 (RFC 5869) with a 32-byte zero salt and type-specific info string labels ("WhatsApp Image Keys", "WhatsApp Video Keys", "WhatsApp Audio Keys", or "WhatsApp Document Keys"). This derives 112 bytes: 16-byte IV, 32-byte Cipher Key, 32-byte MAC Key, and 32-byte Ref Key. The payload integrity is validated via HMAC-SHA256 (10-byte truncated trailer computed over IV + ciphertext) before deciphering with AES-256-CBC.
Why does pulling media through WhatsApp Web use so much memory?
whatsapp-web.js, which the Klaros desktop app uses for personal numbers, returns a downloaded file from the browser page as a base64 string over the Chrome DevTools Protocol. Base64 is about a third larger than the file, the string is then decoded into a second copy, and each download waits its turn in one serial queue. Downloading the encrypted file directly and decrypting it in Node avoids the page round trip and the base64 copy entirely.
What does content-addressed storage mean in the Klaros media cache?
The decrypted file is named by the SHA-256 of its own bytes (blobs/<sha256>.bin), so identical media, such as one photo forwarded into several chats, is stored once. Each message has a small record, named by a hash of its session and message id, that points at the blob. Both are written to a .tmp file and renamed into place, so a reader never sees a partial file. The cache is capped at 5 GB and evicts the least recently used files first.
How does RFC 7233 byte-range HTTP 206 streaming solve audio/video scrubbing lag?
HTML5 <audio> and <video> elements rely on HTTP Range: bytes=start-end headers to fetch specific byte chunks (such as MP4 MOOV atom headers located at the end of files). Klaros returns HTTP 206 Partial Content with Content-Range and Accept-Ranges: bytes headers. When an operator scrubs a timeline or closes a preview lightbox, the server handles req.on('close') by immediately destroying the underlying file stream.
How does the Klaros inbox avoid layout shift when images load?
When WhatsApp’s message data includes an image’s width and height, the inbox sets the container’s aspect-ratio before the image downloads, so the thread does not jump as photos arrive. When those dimensions are missing, the container uses a default ratio until the image loads.
What happens when direct decryption fails?
A file whose HMAC does not verify is rejected, never decrypted on a best-effort basis, because the result would be cached. The direct download is only made over HTTPS to whatsapp.net hosts, is size-capped while it streams, and follows no redirects. Any failure falls back to WhatsApp Web’s own download, and if the disk cache cannot be written the file is served from memory rather than failing.
Does this apply to WhatsApp Business numbers on the Cloud API?
No. This pipeline is the Klaros desktop app’s path for personal numbers connected through WhatsApp Web. Business numbers on Meta’s Cloud API receive media through Meta’s Media API, which Klaros’s cloud service handles separately.
Written 23 September 2026. Klaros — Built to Remember Your Story. Related: our philosophy, Trengo alternative, Twilio alternative, WATI alternative, performance notes, all build notes.