Volver al blogMTProto

GramJS sessions: cifrado, rotación y los errores que matan tu producto

Una StringSession es la llave del reino. AES-256-GCM, rotación de auth_key, lock distribuido y los 5 errores que vemos quemar cuentas.

Robinson Silverio22 de mayo de 20267 min de lectura
GramJS sessions: cifrado, rotación y los errores que matan tu producto

Una StringSession válida es literalmente las llaves del reino: con ella, cualquiera opera como esa cuenta, envía mensajes, accede a chats y secretos. Si tratas las sesiones de tus usuarios como guardas tu clave de WiFi, tu producto tiene un riesgo de seguridad inaceptable.

Esta guía cubre el camino correcto: cómo cifrarlas, cómo rotarlas, cómo no leakearlas en logs y los 5 errores que vemos quemar cuentas y reputación de SaaS de Telegram.

Qué es una StringSession exactamente

Cuando autenticas un cliente GramJS con un número de teléfono, Telegram te devuelve un auth_key permanente (256 bytes). GramJS empaqueta ese key + DC info + algunos metadatos en una string base64 que llamamos StringSession.

Esa string es permanente hasta que:

  • El usuario revoque la sesión desde su app Telegram, o
  • Telegram la invalide por seguridad (cambio de número, sospecha de spam, etc.)

Mientras viva, cualquier copia de esa string da acceso completo a la cuenta. Por eso es un activo crítico, no un dato más.

Lo mínimo para no leakear: cifrado en reposo

Guardar la StringSession en plain text en tu DB es como guardar contraseñas en una hoja de Excel. Es el error 1 y el más común en proyectos jóvenes.

El estándar correcto es AES-256-GCM con:

  • Clave maestra en variable de entorno (ENCRYPTION_KEY, 32 bytes hex)
  • IV (initialization vector) único por sesión, generado con randomBytes
  • Authentication tag que verifica integridad del cifrado
  • Almacenamiento = IV + tag + ciphertext en formato iv:tag:cipher (hex)

En Node.js es directo con crypto:

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
 
const ALG = "aes-256-gcm";
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, "hex");
 
export function cifrar(plano: string): string {
  const iv = randomBytes(16);
  const cipher = createCipheriv(ALG, KEY, iv);
  const enc = Buffer.concat([cipher.update(plano, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
}
 
export function descifrar(payload: string): string {
  const [iv, tag, enc] = payload.split(":").map((p) => Buffer.from(p, "hex"));
  const decipher = createDecipheriv(ALG, KEY, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
}

Cualquier solución que no use GCM o no firme con auth tag es inferior. Evita aes-256-cbc puro, te deja vulnerable a tampering.

Rotación de la clave maestra

La ENCRYPTION_KEY no es eterna. Rotarla cada 90 días limita el daño si se filtra.

Proceso correcto:

  1. Generar nueva ENCRYPTION_KEY_V2
  2. Para cada sesión cifrada con V1, descifrar con V1 → cifrar con V2 → actualizar DB
  3. Una vez TODO está en V2, eliminar V1
  4. Mantener V1 + V2 disponibles durante la rotación (1-2 horas) para evitar downtime

Los proyectos serios automatizan esto con un job de Postgres + un endpoint admin que dispara la rotación. En operación con 10,000 cuentas, rotar puede tomar minutos.

AUTH_KEY_DUPLICATED: el ban silencioso

Telegram solo permite una conexión activa por auth_key a la vez. Si dos procesos abren la misma sesión simultáneamente, Telegram emite AUTH_KEY_DUPLICATED y mata las dos conexiones. Tu sesión queda en estado raro y, si pasa repetidamente, Telegram la invalida.

Detalle completo aquí, pero la solución corta:

Lock distribuido por cuenta con Redis:

async function conLockConexionMTProto(accountId: string, fn: () => Promise<T>) {
  const lockKey = `mtproto:lock:${accountId}`;
  const acquired = await redis.set(lockKey, "1", "EX", 30, "NX");
  if (!acquired) {
    // alguien ya tiene la cuenta, espera o falla
    await esperarOFallar(lockKey);
  }
  try {
    return await fn();
  } finally {
    await redis.del(lockKey);
  }
}

Sin lock, con 10 workers procesando cola, vas a tener AUTH_KEY_DUPLICATED en producción. Garantizado.

Los 5 errores que vemos quemar cuentas

Error 1: Sesiones en plain text en la DB

Ya cubierto arriba. Cifrar es obligatorio, no negociable. Audit log de cualquier acceso a la sesión.

Error 2: Sesiones serializadas en logs

logger.info(`Conectando cliente: ${JSON.stringify(account)}`);

Si account incluye la sesión, ahora está en tus logs (Datadog, Sentry, CloudWatch). Los logs no son cifrados, son leíbles por cualquier dev con acceso. Vimos a un equipo perder 200 cuentas porque la sesión apareció en un dump de logs compartido en Slack.

Regla: nunca log la sesión completa, ni siquiera en debug. Si necesitas debug, log solo accountId + DC + fingerprint hash.

Error 3: Compartir sesiones entre servicios

Es tentador pasar la StringSession desde el backend al worker via mensaje en cola. Mal. Pasa el accountId y deja que el worker la lea del storage cifrado con la clave maestra que tiene. La sesión vive en un solo lugar: la DB.

Error 4: No invalidar sesión cuando el usuario cierra

Si el usuario cierra su cuenta en tu producto, tienes que revocar la sesión en Telegram llamando al método auth.LogOut. Si solo borras de tu DB, esa sesión sigue válida — y si alguien la copió antes, sigue teniendo acceso.

await client.invoke(new Api.auth.LogOut());

Esa llamada le dice a Telegram "esta sesión murió, revócala". Solo después borras de tu DB.

Error 5: Compartir clave maestra en todos los servicios

Si la ENCRYPTION_KEY está disponible en todos tus contenedores (backend, worker, admin panel, scripts cron), un compromiso de cualquiera de ellos compromete todas las sesiones.

Mejor práctica: separar por servicio. Solo el servicio que necesita descifrar (worker MTProto) tiene la clave. El frontend nunca toca sesiones cifradas. Tener un servicio "vault" central que distribuye sesiones bajo demanda es lo ideal para escalar.

Auditoría de seguridad: lo mínimo

Antes de poner tu producto Telegram en producción, verifica:

  • AES-256-GCM con IV único por sesión
  • Clave maestra rotada cada 90 días
  • Lock distribuido Redis con TTL antes de cada conexión MTProto
  • Sesiones nunca en logs (audit con grep en código)
  • auth.LogOut al cerrar cuenta de usuario
  • Acceso a sesiones limitado a 1-2 servicios
  • Monitoring de errores AUTH_KEY_DUPLICATED con alerta

Si faltan items, no es opcional: es deuda de seguridad que se cobra sola.

El bonus: cifrado a nivel de aplicación + a nivel de DB

Para clientes con requisitos de compliance (PCI, GDPR, SOC 2), agrega doble cifrado:

  1. A nivel de aplicación: AES-256-GCM como describimos arriba
  2. A nivel de DB: TDE (Transparent Data Encryption) en Postgres con pgcrypto o storage cifrado de tu proveedor (Supabase, Neon, RDS)

Esto te da defensa en profundidad: si alguien dumpea la DB, necesita ambas claves para descifrar.

Cómo lo hace Vega Punk

Vega Punk usa AES-256-GCM con ENCRYPTION_KEY en variable de entorno aislada del frontend, lock distribuido Redis con TTL 30s para cada conexión, audit log de cualquier acceso a sesiones, y auth.LogOut cuando el cliente desconecta una cuenta. La clave maestra rota cada 90 días con un job programado.

No es magia, es la receta correcta. La diferencia entre hacerlo bien y mal es la diferencia entre dormir tranquilo y tener una brecha de seguridad pública en seis meses.

Cierre

La sesión de Telegram es uno de los activos más valiosos de tu producto. Trátala como tal: cifrada, rotada, aislada, auditada. Si no te sale natural pensar en estos términos, contrata a alguien que sí — o usa una plataforma como Vega Punk que ya lo tiene resuelto.

El día que tengas tu primera brecha de seguridad, vas a desear haber leído este post seis meses antes.

#gramjs#seguridad#mtproto#cifrado