Перейти к содержанию

Telegram Mini Apps: проверка initData на сервере

В предыдущем уроке мы отделили сырую строку запуска от удобного объекта интерфейса. Теперь проверим initData на серверной стороне. Для мини-приложения «Учебный план» это граница доверия: только после подтверждения подписи и времени можно связать запрос с идентификатором Telegram. Результат урока — полная функция проверки, которую затем сможет вызвать отдельный HTTP-адаптер. Сам сервер сессий и настоящий бот в этом шаге не создаются.

Telegram описывает два разных способа проверки. Когда ваш сервер владеет bot token, используется HMAC. Для независимой третьей стороны предусмотрен другой протокол с Ed25519 и публичным ключом. Мы реализуем первый способ, не смешивая его строку данных со вторым. Алгоритмы приведены в официальной документации Mini Apps. Bot token остаётся только на сервере, не попадает в JavaScript, архив, URL и журнал запросов.

Два вычисления HMAC

Сначала вычисляется секретный ключ: HMAC-SHA-256, где ключ — строка WebAppData, а сообщение — bot token. Затем этот бинарный результат используется как ключ HMAC для строки данных. В строке поля упорядочены по имени, представлены как key=value и разделены LF. Полученный digest сравнивается с переданным hash. Перестановка аргументов первого HMAC даёт другой результат и является ошибкой, хотя названия функции и параметров внешне похожи.

Для HMAC-способа исключаем из набора именно hash; остальные полученные поля, включая signature, если она присутствует, участвуют согласно текущему описанию. Правило исключить одновременно hash и signature относится к отдельно описанной строке third-party Ed25519. Не переносите его сюда по сходству имён. Мы также не пересобираем JSON пользователя: подписано его исходное декодированное строковое представление, включая порядок свойств и пробелы.

Функция ниже — полный файл server/validate-init-data.mjs, рассчитанный на стандартные API Node.js 24.x. Это семейство среды, не скачанный точный патч. Реальную установленную версию читатель фиксирует отдельно. createHmac и timingSafeEqual определены в документации Node crypto. Внешних пакетов здесь нет, но приложение и криптографические сценарии при подготовке не выполнялись.

import {createHmac, timingSafeEqual} from 'node:crypto';

export function validateInitData(raw, botToken, now = Math.floor(Date.now() / 1000)) {
  if (typeof botToken !== 'string' || !botToken) throw new Error('Server configuration');
  if (typeof raw !== 'string' || !raw || raw.length > 8192 || !Number.isSafeInteger(now)) {
    throw new Error('Invalid launch data');
  }
  if (/%(?![0-9a-fA-F]{2})/.test(raw)) throw new Error('Invalid launch data');
  const entries = [...new URLSearchParams(raw).entries()];
  if (entries.length > 32) throw new Error('Invalid launch data');
  const seen = new Set();
  for (const [key] of entries) {
    if (!key || seen.has(key)) throw new Error('Invalid launch data');
    seen.add(key);
  }
  const data = Object.fromEntries(entries);
  if (!/^[0-9a-f]{64}$/.test(data.hash ?? '')) throw new Error('Invalid launch data');
  const checkString = entries.filter(([key]) => key !== 'hash')
    .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
    .map(([key, value]) => `${key}=${value}`).join('\n');
  const secret = createHmac('sha256', 'WebAppData').update(botToken, 'utf8').digest();
  const expected = createHmac('sha256', secret).update(checkString, 'utf8').digest();
  const received = Buffer.from(data.hash, 'hex');
  if (!timingSafeEqual(expected, received)) throw new Error('Invalid launch data');
  if (!/^[0-9]{1,12}$/.test(data.auth_date ?? '')) throw new Error('Invalid launch data');
  const authDate = Number(data.auth_date);
  if (!Number.isSafeInteger(authDate) || now - authDate > 300 || authDate - now > 30) {
    throw new Error('Invalid launch data');
  }
  let user;
  try { user = JSON.parse(data.user ?? ''); }
  catch { throw new Error('Invalid launch data'); }
  if (!user || !Number.isSafeInteger(user.id) || user.id <= 0) {
    throw new Error('Invalid launch data');
  }
  return Object.freeze({
    telegramUserId: String(user.id),
    authDate,
    startParam: typeof data.start_param === 'string' ? data.start_param : null
  });
}

Разбор входа до доверия

URLSearchParams декодирует пары query string. Проверка повторяющихся ключей нужна, потому что разные обработчики могут выбрать первое или последнее значение. Нельзя позволять одному слою проверять auth_date из первой пары, а другому — принимать вторую. Поэтому повтор любого имени приводит к отказу до создания объекта. Предел 8192 относится к длине строки в JavaScript, не к байтовому ограничению HTTP-тела. HTTP-адаптер обязан отдельно ограничить принимаемый запрос.

Проверка percent-последовательности отбрасывает очевидно неправильный ввод вроде %ZZ, но не является самостоятельной системой аутентификации. Основное доказательство — HMAC. Неизвестное поле не выбрасываем перед вычислением подписи, иначе создавали бы другую строку, чем подписанный набор Telegram. После проверки функция использует только нужные приложению значения. Полноценная политика допуска новых полей и формат HTTP-ошибок относятся к адаптеру, а не к ручному игнорированию части подписи.

timingSafeEqual получает два буфера одинакового размера. Сначала ограничиваем формат hash 64 строчными шестнадцатеричными символами, поэтому полученный буфер соответствует размеру SHA-256. Такое сравнение уменьшает зависимость времени от позиции различия байтов. Оно не делает всю программу автоматически постоянной по времени и не отменяет обычных границ запроса. Подробные сообщения о том, какой байт неправильный, приложению не нужны: клиент получает общий отказ подтверждения запуска.

Подпись и свежесть

Правильная подпись подтверждает происхождение и целостность набора, но не делает его бессрочным. Наше приложение допускает возраст до 300 секунд и будущее время не более 30 секунд относительно серверных часов. Это учебная политика «Учебного плана», не универсальное требование Telegram. Порог нужно согласовать с процессом открытия и задержками сети. Параметр now используется для чистого воспроизводимого сценария; HTTP-адаптер берёт его на сервере, никогда из тела пользователя.

Из-за выбранного окна интерфейс не должен отправлять старую строку запуска как бесконечный токен на каждое изменение плана. В следующем уроке обменяем свежий подтверждённый запуск на собственную ограниченную сессию. Проверка свежести также не является одноразовым потреблением: в пределах окна строку можно повторить. Если проект требует защиту от конкретного повторного обмена, нужен серверный реестр и отдельная политика. Наша функция такого реестра не реализует.

JSON пользователя разбирается только после подписи и времени. Для нашей модели требуется положительный целочисленный ID; возможные режимы открытия без user не подходят и получают отказ. Имя, username и фото не становятся ключом владельца плана. Telegram предупреждает о ширине идентификаторов, поэтому не используем 32-битное приведение. Функция возвращает ID строкой для последующего серверного хранения, не копирует весь объект пользователя в журналы и клиентский ответ.

Проверенный startParam тоже остаётся входными данными сценария, а не разрешением доступа. Telegram может подтвердить, что строка пришла в данном запуске, но пользователь сам выбирает ссылку открытия. Если она содержит чужой идентификатор плана, сервер всё равно проверяет владельца ресурса. Не превращайте подписанный контекст в административную роль или команду произвольного выполнения. Функция возвращает его отдельно, чтобы следующий слой мог разобрать разрешённую форму и отдать понятный отказ неизвестному сценарию.

HTTP-адаптер также должен ограничить частоту попыток обмена и не записывать raw initData в обычный журнал. В пределах выбранного окна это пригодный материал для повторного подтверждения запуска. Для диагностики достаточно кода причины и внутреннего идентификатора запроса без bot token и полного JSON пользователя. Такой адаптер в примере отсутствует, поэтому функция не считается готовым защищённым endpoint.

Ожидаемые сценарии

С корректной строкой собственного бота и допустимым auth_date ожидается объект с telegramUserId. Изменение одной буквы в user, другой bot token, повтор hash, неправильный возраст и некорректный ID должны привести к отказу. Строка, взятая из initDataUnsafe и заново сериализованная браузером, не заменяет исходный raw initData. Отдельно проверяется случай присутствующей signature: для нашего HMAC-способа она остаётся среди подписываемых полей.

Ни один из этих сценариев здесь не запускался, bot token не получался и реальный запуск Telegram не выполнялся. Архив хранит функцию и матрицу ожидаемых условий без секретов. Даже успешная функция ещё не создаёт авторизацию доступа к чужому плану: она подтверждает идентичность запуска. Собственная сессия, проверка владельца ресурса, срок действия и отзыв доступа составят следующий серверный контракт.

Оглавление серии