Мобильный протокол MTProto

Примечание

Разработчикам клиентов нужно соблюдать наши требования безопасности.

Связанные статьи

Общее описание

Протокол предназначен для доступа к серверному API с приложений, запущенных на мобильных устройствах. Подчеркнем, что интернет-браузер не считается таким приложением.

Протокол разбит на три почти независимых части:

  • Высокоуровневая часть (язык запросов к API) — определяет, каким образом запросы к API и ответы на эти запросы преобразуются в двоичные сообщения.
  • Криптографическая (авторизационная) прослойка — определяет, каким образом сообщения шифруются перед передачей через транспортный протокол.
  • Транспортная часть — определяет, каким образом передаются сообщения между клиентом и сервером поверх какого-либо другого существующего сетевого протокола (например, http, https, tcp, udp).
MTProto encryption

Примечание 1: Каждое текстовое сообщение, которое нужно зашифровать через MTProto, всегда содержит следующие данные, которые проверяются расшифровкой, чтобы сделать систему устойчивой против известных проблем с компонентами:

  • server salt (соль сервера) (64-битная)
  • session id (идентификатор сессии)
  • message sequence number (порядковый номер сообщения)
  • message length (длина сообщения)
  • time (время)

Краткий обзор компонентов

Высокоуровневая часть (язык RPC-запросов/API)

С точки зрения высокоуровневой части, клиент и сервер обмениваются сообщениями в рамках некоторой сессии. Сессия привязана к клиентскому устройству (вернее, приложению), но не к конкретному http/https/tcp-соединению. Кроме того, каждая сессия привязана к идентификатору пользовательского ключа, по которому фактически производится авторизация.

Может быть открыто несколько соединений к серверу; сообщения в ту или иную сторону могут идти по любому из них (ответ на запрос не обязан прийти по тому же соединению, по которому был отправлен сам запрос, хотя чаще всего это так; однако ни в коем случае сообщение не может быть возвращено в соединении, принадлежащем другой сессии). При использовании UDP-протокола может случиться, что ответ на запрос приходит не с того IP, на который был отправлен запрос.

Сообщения бывают нескольких типов:

  • RPC-вызовы (от клиента к серверу) — обращения к методам API
  • RPC-результаты (от сервера к клиенту) — результаты RPC-вызовов
  • Подтверждение приема сообщений (вернее, уведомление о состоянии набора сообщений)
  • Запрос состояния сообщений
  • Составное сообщение или контейнер (контейнер, содержащий несколько сообщений; нужен, например, чтобы по HTTP-соединению можно было отправить несколько RPC-вызовов сразу; кроме того, контейнер может поддерживать gzip).

С точки зрения протоколов более низкого уровня, сообщение — это поток двоичных данных, выровненный по границе 4 или 16 байтов. Первые несколько полей сообщения фиксированы и используются системой криптографии/авторизации.

Каждое сообщение, отдельное или внутри контейнера, состоит из идентификатора сообщения (64 бита; см. ниже), порядкового номера сообщения в сессии (32 бита), длины (тела в байтах; 32 бита) и тела (любой размер, кратный 4 байтам). Кроме того, при отправке контейнера или одиночного сообщения, в его начало дописывается внутренний заголовок (см. ниже), после чего все это шифруется, и в начало зашифрованного сообщения добавляется внешний заголовок (64-битный идентификатор ключа и 128-битный ключ сообщения).

Тело сообщения обычно состоит из 32-битного типа сообщения, за которым следуют параметры, зависящие от типа. В частности, каждой RPC-функции соответствует свой тип сообщения. Более подробно читайте в статье про двоичную сериализацию данных и служебные сообщения.

Все числа записываются как little-endian. Однако очень большие числа (2048-битные), используемые в RSA и DH, записываются как big-endian, потому что так делает библиотека OpenSSL.

Авторизация и криптография

Перед передачей сообщений (или составных сообщений) по сети посредством транспортного протокола они шифруются определенным образом; при этом перед сообщением приписывается внешний заголовок: 64-битный идентификатор ключа (однозначно определяющий авторизационный ключ для сервера, а также пользователя) и 128-битный ключ сообщения. Пользовательский ключ вместе с ключом сообщения определяют реальный 256-битный ключ, которым и зашифровано сообщение посредством шифра AES-256. Начало тела незашифрованного сообщения содержит некоторые данные (сессию, идентификатор сообщения, порядковый номер сообщения в сессии, серверную соль); ключ сообщения должен совпадать с младшими 128 битами SHA1 от тела сообщения (включая сессию, идентификатор сообщения и т.п.). Составные сообщения шифруются как единое целое.

Первым делом клиентское приложение должно произвести создание авторизационного ключа, который обычно создается при первом запуске и практически никогда не изменяется.

Совет

Примечание от переводчика: по некоторым сведениям, в последних обновлениях этот ключ меняется через каждые 100 отправленных сообщений — https://twitter.com/durov/status/539489480676085760

Основной недостаток протокола — в том, что злоумышленник, пассивно перехватывающий сообщения, а затем каким-либо образом заполучивший авторизационный ключ (например, украв устройство) получит возможность расшифровать все перехваченные сообщения post factum. Вероятно, это не слишком серьезно (украв устройство, можно получить и всю закешированную на нем информацию, ничего не расшифровывая), однако для преодоления этих проблем можно сделать следующее:

  • Сессионные ключи, генерируемые по протоколу Диффи-Хелмана, и используемые совместно с авторизационным ключом и ключом сообщения для выбора параметров AES. Для их создания клиент должен первым действием после создания новой сессии отправить серверу специальный RPC-запрос («сгенерировать сессионный ключ»), сервер ответит на него, после чего все последующие сообщения сессии шифруются с учетом и сессионного ключа.
  • Защищать ключ, хранимый на клиентском устройством, (текстовым) паролем; этот пароль никогда не хранится в памяти и вводится пользователем при запуске приложения или чаще (в зависимости от настроек приложения).
  • Данные, хранимые (кэшируемые) на пользовательском устройстве, можно также защищать, шифруя с помощью авторизационного ключа, который, в свою очередь, надо защитить паролем. Тогда без ввода пароля невозможно будет получить доступ даже к этим данным.

Синхронизация времени

Если время на клиенте сильно отличается от времени на сервере, может так получиться, что сервер начнет игнорировать сообщения клиента, или наоборот, из-за некорректного значения идентификатора сообщения (которое тесно связано с временем создания). В таких ситуациях сервер шлет клиенту специальное сообщение с правильным временем, содержащие, помимо него, некую 128-битную соль (либо явно присланную клиентом в специальном RPC-запросе синхронизации, либо равную ключу последнего сообщения, полученного от клиента в рамках данной сессии). Такое сообщение может быть первым в контейнере, содержащим и другие сообщения (если рассинхронизация существенна, но еще не приводит к игнорированию клиентских сообщений).

При получении такого сообщения (или содержащего его контейнера) клиент сначала выполняет синхронизацию времени (фактически всего лишь запоминает разницу своего и серверного времени, чтобы уметь впредь вычислять «правильное» время), а затем проверяет идентификаторы сообщений на корректность.

В запущенных случаях клиенту придется сгенерировать новую сессию, чтобы обеспечить монотонность идентификаторов сообщений.

Транспорт

Позволяет доставлять уже зашифрованные контейнеры вместе с внешним заголовком (в дальнейшем — полезную нагрузку) от клиента к серверу и наоборот. Есть три типа транспорта:

  • HTTP
  • TCP
  • UDP

Рассмотрим первые два типа.

Передача по HTTP

Реализуется поверх HTTP/1.1 (с keepalive), запущенного поверх классического TCP-порта 80. HTTPS не используется; используется криптографическая схема, объясненная выше.

HTTP-соединение привязывается к сессии (вернее, сессии + идентификатору ключа), указанной в последнем пришедшем пользовательском запросе; обычно во всех запросах сессия одинакова, однако хитрые HTTP-прокси могут это испортить. Сервер может вернуть сообщение в HTTP-соединение только в том случае, если оно принадлежит той же сессии, и если сейчас очередь сервера (был получен HTTP-запрос от клиента, на который еще не было отправлено ответа).

Общая схема такова. Клиент открывает одно или несколько keepalive HTTP-соединений к серверу. При необходимости отправки одного или нескольких сообщений из них составляется полезная нагрузка, после чего делается POST-запрос на URL /api, в качестве данных которому и передается полезная нагрузка. Кроме того, допускаются HTTP-заголовки Content-Length, Keepalive, Host.

После получения запроса сервер может либо подождать немного (если запрос подразумевает ответ после небольшого ожидания), либо сразу вернуть фиктивный ответ (сообщающий всего лишь о том, что контейнер был получен). В любом случае в ответе может оказаться сколько угодно сообщений — сервер вправе заодно отправить любые накопившиеся у него сообщения для этой сессии.

Кроме того, есть специальный longpoll RPC-запрос (действительный только для http-соединений), в котором передается максимальное время ожидания T. Если у сервера есть сообщения для этой сессии, они возвращаются сразу же; в противном случае происходит ожидание до тех пор, пока у сервера не появится сообщение для клиента, либо не пройдет T секунд. Если за T секунд не произошло никаких событий, возвращается фиктивный ответ (специальное сообщение).

Если серверу надо отправить сообщение клиенту, он проверяет, нет ли HTTP-соединения, принадлежащего нужной сессии, и находящегося в состоянии «выполнения HTTP-запроса» (включая long poll), после чего сообщение добавляется в контейнер ответа этого соединения и отправляется пользователю. В типичном случае происходит небольшое дополнительное ожидание (50 миллисекунд), на тот случай, если у сервера вскоре появятся еще сообщения для этой сессии.

Если ни одного подходящего HTTP-соединения нет, сообщения ставятся в очередь отправки для данной сессии. Впрочем, они туда попадают в любом случае, пока явно или косвенно не подтверждено получение клиентом. Для http-протокола неявным подтверждением считается отправка следующего запроса по тому же HTTP-соединению (уже нет — и для HTTP-протокола необходимо слать явные подтверждения); в остальных случаях клиент должен прислать явное подтверждение за разумное время (его можно добавить в контейнер для следующего запроса).

Важно: если подтверждение вовремя не пришло, сообщение может быть перепослано (возможно, в составе другого контейнера). Стороны должны быть морально готовы к этому и хранить идентификаторы последних полученных сообщений (и игнорировать такие дубли, а не повторять действие). Для того, чтобы не хранить идентификаторы вечно, есть специальные сообщения сборки мусора, эксплуатирующие монотонность идентификаторов сообщений.

Если очередь отправки переполняется, или сообщения ждут в ней больше 10 минут, то сервер их забывает (или отправляет в своп - дурное дело нехитрое). Это может случиться и быстрее, если у сервера заканчиваются буферы (например, из-за серьезных проблем в сети, приведших к разрыву большого количества соединений).

TCP-транспорт

Очень похож на HTTP-транспорт, может быть реализован тоже на порт 80 (чтобы проходить все фаерволы) и даже на те же ip-адреса серверов. В этом случае сервер понимает, нужно ли использовать HTTP или TCP-протокол для данного соединения по первым четырем пришедшим байтам (для HTTP это будет POST).

При создании TCP-соединения оно приписывается сессии (и авторизационному ключу), переданному в первом сообщении пользователя, и потом используется исключительно для данной сессии (схемы мультиплексирования не допускаются).

При необходимости отправки полезной нагрузки (пакета) от сервера к клиенту или от клиента к серверу она инкапсулируется следующим образом: спереди дописывается 4 байта длины (включая длину, порядковый номер и CRC32; всегда делится на четыре) и 4 байта с порядковым номером пакета внутри данного tcp-соединения (первый отправленный пакет помечается 0, следующий — 1 и т.д.), а в конце — 4 байта CRC32 (длины, порядкового номера и полезной нагрузки вместе).

Существует сокращённая версия этого протокола: если клиент отправляет первым байтом (важно: только перед самым первым пакетом данных) 0xEF, то после этого длина пакета кодируется одним байтом (0×01..0×7E = длина данных, делённая на 4; либо 0×7F, а затем 3 байта длины (little-endian), делённой на 4), а далее идут сами данные (порядковый номер или CRC32 не добавляются). Ответы сервера в этом случае имеют тот же вид (при этом сервер не отсылает первый байт 0xEF).

В случае, если требуется выравнивание 4-байтовых данных, может быть использована промежуточная версия оригинального протокола: если клиент отправляет 0xEEEEEEEE как первый инт (int) (четыре байта), то длина пакета зашифрована всегда четырьмя байтами как в оригинальной версии, но порядковый номер и CRC32 опускаются, таким образом уменьшая итоговый /общий размер пакета на 8 байт.

В полной и в сокращённой версии протокола есть поддержка быстрых подтверждений. В этом случае клиент устанавливает старший бит длины в пакете с запросом, а сервер отсылает в ответ специальные 4 байта, представляющие собой самостоятельный пакет. Они представляют собой старшие 32 бита SHA1 от зашифрованной части пакета, с установленным старшим битом, чтобы было понятно, что это не длина обычного пакета с ответом сервера; если используется сокращённая версия, то к этим четырём байтам применяется bswap.

Неявных подтверждений для TCP-транспорта не бывает: все сообщения должны быть явно подтверждены. Чаще всего подтверждения помещаются в контейнер вместе со следующим запросом или ответом, если он отправляется вскоре. Например, это почти всегда так для сообщений от клиента, содержащих RPC-запросы: подтверждение обычно приходит вместе с RPC-ответом.

В случае возникновения ошибки сервер может прислать пакет, полезная нагрузка которого состоит из 4 байтов — кода ошибки. Например, код ошибки −403 соответствует ситуациям, в которых через HTTP-протокол вернулась бы соответствующая HTTP-ошибка.