Разработчикам клиентов нужно соблюдать наши требования безопасности.
Протокол предназначен для доступа к серверному API с приложений, запущенных на мобильных устройствах. Подчеркнем, что интернет-браузер не считается таким приложением.
Протокол разбит на три почти независимых части:
Примечание 1: Каждое текстовое сообщение, которое нужно зашифровать через MTProto, всегда содержит следующие данные, которые проверяются расшифровкой, чтобы сделать систему устойчивой против известных проблем с компонентами:
С точки зрения высокоуровневой части, клиент и сервер обмениваются сообщениями в рамках некоторой сессии. Сессия привязана к клиентскому устройству (вернее, приложению), но не к конкретному http/https/tcp-соединению. Кроме того, каждая сессия привязана к идентификатору пользовательского ключа, по которому фактически производится авторизация.
Может быть открыто несколько соединений к серверу; сообщения в ту или иную сторону могут идти по любому из них (ответ на запрос не обязан прийти по тому же соединению, по которому был отправлен сам запрос, хотя чаще всего это так; однако ни в коем случае сообщение не может быть возвращено в соединении, принадлежащем другой сессии). При использовании UDP-протокола может случиться, что ответ на запрос приходит не с того IP, на который был отправлен запрос.
Сообщения бывают нескольких типов:
С точки зрения протоколов более низкого уровня, сообщение — это поток двоичных данных, выровненный по границе 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. Вероятно, это не слишком серьезно (украв устройство, можно получить и всю закешированную на нем информацию, ничего не расшифровывая), однако для преодоления этих проблем можно сделать следующее:
Если время на клиенте сильно отличается от времени на сервере, может так получиться, что сервер начнет игнорировать сообщения клиента, или наоборот, из-за некорректного значения идентификатора сообщения (которое тесно связано с временем создания). В таких ситуациях сервер шлет клиенту специальное сообщение с правильным временем, содержащие, помимо него, некую 128-битную соль (либо явно присланную клиентом в специальном RPC-запросе синхронизации, либо равную ключу последнего сообщения, полученного от клиента в рамках данной сессии). Такое сообщение может быть первым в контейнере, содержащим и другие сообщения (если рассинхронизация существенна, но еще не приводит к игнорированию клиентских сообщений).
При получении такого сообщения (или содержащего его контейнера) клиент сначала выполняет синхронизацию времени (фактически всего лишь запоминает разницу своего и серверного времени, чтобы уметь впредь вычислять «правильное» время), а затем проверяет идентификаторы сообщений на корректность.
В запущенных случаях клиенту придется сгенерировать новую сессию, чтобы обеспечить монотонность идентификаторов сообщений.
Позволяет доставлять уже зашифрованные контейнеры вместе с внешним заголовком (в дальнейшем — полезную нагрузку) от клиента к серверу и наоборот. Есть три типа транспорта:
Рассмотрим первые два типа.
Реализуется поверх 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 минут, то сервер их забывает (или отправляет в своп - дурное дело нехитрое). Это может случиться и быстрее, если у сервера заканчиваются буферы (например, из-за серьезных проблем в сети, приведших к разрыву большого количества соединений).
Очень похож на 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-ошибка.