Notas de construcción · Descifrado de Medios de WhatsApp y Motor de Streaming CAS

El pipeline de medios de WhatsApp que se quedó sin memoria: Descifrado HKDF Nativo y Streaming CAS

Descifrado Nativo HKDF-SHA256, Almacenamiento Direccionable por Contenido y Streaming por Rangos de Bytes RFC 7233

· rendimiento de medios y streaming · Fundador, Klaros

Status: Resolved. This postmortem is a historical log. The issue discussed below has been fully fixed.

La aplicación de escritorio de Klaros lee los números personales de WhatsApp a través de WhatsApp Web. Hasta este cambio, cada foto, nota de voz y PDF de un chat en vivo se extraía de la página del navegador como una cadena base64, de uno en uno, y se guardaba en memoria. Así lo sustituimos por descifrado HKDF directo desde los servidores de medios de WhatsApp, una caché en disco direccionada por contenido y streaming HTTP por rangos de bytes.

Resumen y Evidencia Técnica

El Cuello de Botella: whatsapp-web.js devuelve los medios desde la página del navegador como una cadena base64 a través del Chrome DevTools Protocol. Base64 ocupa un tercio más que el archivo, cada descarga pasaba por una única cola en serie y el resultado se guardaba en memoria y se servía entero, así que el audio y el vídeo no podían avanzar ni retroceder.

La Solución: Descargar el archivo cifrado directamente desde mmg.whatsapp.net, derivar sus claves con HKDF-SHA256, verificar su MAC, descifrarlo con AES-256-CBC en Node, guardarlo una sola vez en disco con el SHA-256 de sus bytes como nombre y servirlo con rangos de bytes HTTP 206. Cualquier fallo en ese camino recurre a la descarga propia de WhatsApp Web.

Alcance: este es el camino de los números personales en la aplicación de escritorio. Los números de empresa en la Cloud API reciben los medios a través de la Media API de Meta, que es otro flujo. No hemos publicado mediciones de latencia ni de memoria de este cambio.

WhatsApp Media Pipeline Architecture Infographic
Figura 1: Arquitectura de Descifrado de Medios con Latencia Cero y Streaming CAS de Klaros
mmg.whatsapp.net Binary Cifrado + 32B mediaKey + Hash SHA256 Sobrecarga Scraper Base64 Descifrador HKDF Klaros HKDF-SHA256 (112B) 16B IV | 32B CipherKey 32B MACKey (Verif HMAC) AES-256-CBC Directo Motor CAS Storage Hash SHA256 Contenido Intercambio Atómico .tmp Análisis EXIF 0x0112 Proporción guardada RFC 7233 HTTP 206 Rango de Bytes Búsqueda 45ms Stream Instantáneo
Figura 1: Arquitectura de Descifrado de Medios con Latencia Cero y Streaming CAS de Klaros

1. Criptografía HKDF Nativa: Eliminando Congelamientos de Pestañas en Chrome y Sobrecarga de Memoria

Cuando un cliente envía una nota de voz o una imagen en WhatsApp, el archivo binario no se almacena en los servidores de señalización. En su lugar, se carga en nodos CDN de WhatsApp en mmg.whatsapp.net. El paquete de datos del mensaje contiene únicamente metadatos: la URL cifrada, hashes SHA256 del archivo y una clave mediaKey de 32 bytes.

Para descifrar el contenido de forma nativa sin ejecutar scrapers de navegadores en modo headless, la clave mediaKey de 32 bytes debe expandirse utilizando HKDF-SHA256 (RFC 5869) con un salt nulo de 32 bytes y etiquetas descriptivas UTF-8 específicas para cada tipo:

La derivación HKDF genera 112 bytes de material de clave pseudoaleatorio, fragmentados en cuatro claves funcionales:

Fragmentación de Claves Criptográficas
Segmento de Clave Rango de Bytes Función
Vector de Inicialización (IV) Bytes 0 - 15 (16B) Estado inicial del cifrado AES-256-CBC
Clave de Cifrado (Cipher Key) Bytes 16 - 47 (32B) Clave de descifrado AES-256-CBC
Clave MAC (MAC Key) Bytes 48 - 79 (32B) Validación de integridad del contenido mediante HMAC-SHA256
Clave de Referencia (Ref Key) Bytes 80 - 111 (32B) Clave de validación de referencia
Derivación Nativa de Claves HKDF y Descifrado AES-256-CBC
// 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);

  // Validar la integridad del remate HMAC-SHA256 truncado a 10 bytes
  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('Verificación HMAC fallida: contenido corrupto');
  }

  const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}

2. Almacenamiento Direccionable por Contenido (CAS) y Espacio Reservado: Deteniendo los Saltos de Diseño de Imágenes

Los medios descifrados se guardan direccionados por contenido: el archivo recibe como nombre el SHA-256 de sus bytes (blobs/<sha256>.bin), así que una misma foto reenviada a diez chats se guarda una sola vez. Cada mensaje tiene un pequeño registro (keys/<sha256 de la sesión y el id del mensaje>.json) que apunta a su blob y guarda su tipo MIME, nombre de archivo y dimensiones. Ambos se escriben en un archivo .tmp y se renombran en su sitio, de modo que un lector nunca ve un archivo a medias.

La caché vive en la carpeta de datos del usuario, con un límite de 5 GB. Cada lectura actualiza la fecha del blob y se eliminan primero los usados hace más tiempo, en una pasada en segundo plano y limitada en frecuencia, no durante la petición.

Cuando los datos del mensaje de WhatsApp incluyen el ancho y el alto de una imagen, la bandeja fija el aspect-ratio del contenedor antes de que la imagen cargue, para que el hilo no salte mientras llegan las fotos. Si faltan esas dimensiones, el contenedor usa una proporción por defecto.

Universal Media Engine Modes Infographic
Figura 2: Motor de Medios Universal — Modo A (Carrusel Visualizador Lightbox) y Modo B (Selector de Tarjetas del Compositor)
Modo A: Carrusel Visualizador Lightbox Matemática EXIF Contenedor Relación Aspecto Espacio reservado antes de cargar Modo B: Selector Tarjetas Compositor Micro-Miniaturas SHA256 CAS Adjuntos Instantáneos y Almacenamiento Propio
Figura 2: Motor de Medios Universal — Modo A (Carrusel Visualizador Lightbox) y Modo B (Selector de Tarjetas del Compositor)

3. Streaming HTTP 206 RFC 7233: Eliminando el Bloqueo de Notas de Voz y la Latencia Audio de 4 Segundos

Los reproductores HTML5 <audio> y <video> requieren respuestas de rango de contenido parcial HTTP 206 Partial Content para buscar, desplazarse y transmitir audio sin descargar archivos completos de 50 MB en memoria. Klaros implementa el análisis completo de rangos RFC 7233 admitiendo bytes=100-499, peticiones abiertas bytes=500- y peticiones de sufijo final (bytes=-500 para la búsqueda de encabezados atom MOOV en archivos MP4).

Analizador de Rangos RFC 7233 y Cancelación de Transmisión
// 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()); // Destruir transmisión si el usuario cancela la solicitud a mitad de lectura
  fileStream.pipe(res);
}

4. Cuando falla el camino rápido: verificación MAC, alternativas y una caché que no puede escribir

El camino directo rechaza en lugar de adivinar. Un archivo cuyo HMAC de 10 bytes no verifica se descarta: descifrarlo igualmente guardaría un archivo corrupto o manipulado en una caché que lo serviría durante días. La descarga solo se intenta por HTTPS hacia hosts de whatsapp.net, tiene un tamaño máximo mientras llega y no sigue redirecciones.

Cualquier fallo en ese camino (MAC incorrecto, tiempo agotado, URL caducada) recurre a downloadMedia() de WhatsApp Web, más lento pero fiable. Y si la caché en disco no se puede escribir, el archivo se sirve desde memoria en lugar de fallar la petición.

Los medios en caché se identifican por sesión además de por mensaje, porque una instalación de escritorio puede tener varios perfiles de WhatsApp, y se sirven con Cache-Control: private. Es contenido privado de chats.

Gestiona tu bandeja de WhatsApp en infraestructura propia

Despliega Klaros en tu propia cuenta de Cloudflare sin margen por mensaje.

Preguntas que la gente hace sobre el descifrado de medios en WhatsApp, almacenamiento CAS y streaming RFC 7233

¿Cómo funciona internamente el descifrado de medios en WhatsApp?

El descifrado de medios en WhatsApp requiere obtener un blob binario cifrado desde mmg.whatsapp.net y expandir su mediaKey de 32 bytes mediante HKDF-SHA256 (RFC 5869) con un salt nulo de 32 bytes y etiquetas de texto descriptivas según el tipo ("WhatsApp Image Keys", "WhatsApp Video Keys", "WhatsApp Audio Keys" o "WhatsApp Document Keys"). Esto deriva 112 bytes: IV de 16 bytes, Clave de Cifrado de 32 bytes, Clave MAC de 32 bytes y Clave de Referencia de 32 bytes. La integridad del paquete se valida mediante HMAC-SHA256 (un remate truncado de 10 bytes calculado sobre IV + texto cifrado) antes de descifrar con AES-256-CBC.

¿Por qué extraer los medios a través de WhatsApp Web consume tanta memoria?

whatsapp-web.js, que la aplicación de escritorio de Klaros usa para números personales, devuelve cada archivo descargado desde la página del navegador como una cadena base64 a través del Chrome DevTools Protocol. Base64 ocupa alrededor de un tercio más que el archivo, la cadena se decodifica después en una segunda copia y cada descarga espera su turno en una única cola. Descargar el archivo cifrado directamente y descifrarlo en Node evita el viaje por la página y la copia base64.

¿Qué significa almacenamiento direccionado por contenido en la caché de medios de Klaros?

El archivo descifrado recibe como nombre el SHA-256 de sus propios bytes (blobs/<sha256>.bin), así que un medio idéntico, como una foto reenviada a varios chats, se guarda una sola vez. Cada mensaje tiene un pequeño registro, nombrado con un hash de su sesión y su id, que apunta al blob. Ambos se escriben en un archivo .tmp y se renombran en su sitio, de modo que nunca se lee un archivo parcial. La caché tiene un límite de 5 GB y elimina primero lo usado hace más tiempo.

¿Cómo resuelve el streaming HTTP 206 por rangos de bytes RFC 7233 el retraso en el desplazamiento de audio y video?

Los elementos HTML5 <audio> y <video> dependen de los encabezados HTTP Range: bytes=inicio-fin para solicitar fragmentos de bytes específicos (como los encabezados atom MOOV de MP4 al final del archivo). Klaros devuelve respuestas HTTP 206 Partial Content con encabezados Content-Range y Accept-Ranges: bytes. Cuando un operador desplaza la línea de tiempo o cierra una ventana previa, el servidor gestiona req.on('close') destruyendo inmediatamente la transmisión de archivos.

¿Cómo evita la bandeja de Klaros los saltos de diseño al cargar imágenes?

Cuando los datos del mensaje de WhatsApp incluyen el ancho y el alto de una imagen, la bandeja fija el aspect-ratio del contenedor antes de descargarla, para que el hilo no salte mientras llegan las fotos. Si faltan esas dimensiones, el contenedor usa una proporción por defecto hasta que la imagen carga.

¿Qué pasa cuando falla el descifrado directo?

Un archivo cuyo HMAC no verifica se rechaza, nunca se descifra a medias, porque el resultado quedaría en caché. La descarga directa solo se hace por HTTPS hacia hosts de whatsapp.net, tiene un tamaño máximo y no sigue redirecciones. Cualquier fallo recurre a la descarga propia de WhatsApp Web, y si la caché no se puede escribir el archivo se sirve desde memoria.

¿Se aplica esto a los números de WhatsApp Business en la Cloud API?

No. Este flujo es el de la aplicación de escritorio de Klaros para números personales conectados a través de WhatsApp Web. Los números de empresa en la Cloud API de Meta reciben los medios a través de la Media API de Meta, que el servicio en la nube de Klaros gestiona por separado.

Escrito el 23 de septiembre de 2026. Klaros — Diseñado para recordar su historia. Relacionado: nuestra filosofía, notas de rendimiento, todas las notas de construcción.