Создание API на основе протокола MTProto

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В уже  далеком 2019 Telegram объявил конкурс на создание веб-версии своего мессенджера, в котором мне удалось поучаствовать. По итогу у меня осталась библиотека, которая может работать с API Telegram по протоколу MTProto. Полученный опыт вдохновил меня реализовать протокол MTProto для бэкенда. Разработку вел на python, так как я этот язык хорошо знаю и был уверен, что смогу на нем реализовать свою идею. Для удобства использовал библиотеку aiohttp для соединения по web-socket’у, а для описания структур использовал typings и dataclass.

Структура протокола MTProto:

 Протокол MTProto можно поделить на три независимые части:

  1. Верхнеуровневая часть (API и запросы к нему) - описывает, как вызовы и ответы API могут быть переведены в двоичный код.

  2. Криптографическая (Авторизационная) часть  - описывает, как сообщения будут зашифрованы/расшифрованы.

  3. Транспортная часть - описывает, как зашифрованное сообщение будет передано по одному из протоколов: (HTTP, HTTPS, WS, WSS, TCP, UDP)

Верхнеуровневая часть API - Схема(schema)

Схема телеграма описывает какие есть доступные типы и функции для API. Благодаря схеме клиент знает как сериализовать/десериализовать входящие и исходящие сообщения. Схема описывается на языке TL или с помощью JSON.

Базовые типы:

При описании конструкторов, типов, и функций доступных в схеме используются базовые типы int, long, doubles, bytes, string. Для них описано как они будут  сериализованы и десериализованы:

  1. int - 32 битное знаковое целое, для дампа которого используется little-endian порядок байтов

  2. long - 64 битное знаковое целое, для дампа которого используется little-endian порядок байтов

  3. double - 64 битное число с плавающей запятой

  4. bytes - последовательность байтов определенной длины:

    • Если длина последовательности меньше либо равна 253, то в первый байт мы записываем длину последовательности, а в конце добавляем нулевые байты так, что общая длина последовательности делилась на 4.

    • Если длина последовательности  больше 253, то в первый байт мы записываем число 254, дальше идут 3 байта в порядке little-endian,  описывающие длину последовательности, сама последовательность, ну и в конце добавляем нулевые байты, чтобы общая длина делилась на 4.

  5. string - строка состоящая из UTF-8 символов, для сериализации используется тот же алгоритм, что и для bytes.

Также в MTProto поддерживаются типы int128/int256, но они используются только для шифрования.

Конструкторы и типы данных:

С помощью базовых типов можно описать комплексные структуры. Самая простая комплексная структура - конструктор. Атрибутами конструктора могут быть базовые или пользовательские типы данных.

Для описания конструктора на языке python мы воспользуемся модулем dataclass. Объявим класс, в котором будут описаны все атрибуты конструктора(их может и не быть вовсе). Также объявим классMeta,у которого есть два свойства name и order. В свойстве name храним название  конструктора, а в order - порядок сериализации/десериализации атрибутов конструктора.

from dataclasses import dataclass

from aiohttp import web

from mtpylon import Schema


@dataclass
class Reply:
    rand_id: int
    content: str

    class Meta:
        name = 'reply'
        order = ('rand_id', 'content')

У конструктора могут быть опциональные поля, описанные с помощью типа Optional и функции dataclasses.field, определяющую метаданные для этого поля. Параметр flag определяет какой бит атрибута flag будет индикатором того, определён или нет описанный атрибут.

from dataclasses import dataclass, field

@dataclass
class AuthorizedUser:
    id: int
    username: str
    password: str
    avatar_url: Optional[str] = field(metadata={'flag': 0})

    class Meta:
        name = 'authorizedUser'
        order = ('id', 'username', 'password', 'avatar_url')


Атрибутом структуры может быть список. Для определения списка используется тип typing.List с указанием базового или пользовательского типа для элементов списка

from typing import List
from dataclasses import dataclass

from .task import Task


@dataclass
class TaskList:

   tasks: List[Task]

   class Meta:
       name = 'task_list'
       order = ('tasks',)

Пользовательский тип данных может быть представлен в виде объединения нескольких конструкторов. Для этого используется типы языка Union и Annotated:

@dataclass
class BoolTrue:
   class Meta:
       name = 'boolTrue'


@dataclass
class BoolFalse:
   class Meta:
       name = 'boolFalse'


Bool = Annotated[
   Union[BoolTrue, BoolFalse],
   'Bool'
]


 @dataclass
class AnonymousUser:
   class Meta:
       name = 'anonymous_user'


@dataclass
class RegisteredUser:
   id: int
   nickname: str

   class Meta:
       name = 'registered_user'

       order = ('id', 'nickname')


User = Annotated[
   Union[
       AnonymousUser,
       RegisteredUser
   ],
   'User'
]

Для сериализации/десериализации пользовательских типов данных нужно знать какой конструктор был закодирован.Для этого мы будем использовать 32-битное знаковое число, вычисленное из строки описания конструктора алгоритмом crc32.

Описание конструктора - строка вида:

constructor_name attr_1:AttrType attr_2:AttrType2 … attr_name_n=AttrTypeN = ResType

Зная конструктор или число конструктора, можно сериализовать/десериализовать его содержимое в нужном порядке. 

К примеру для конструктора RegisteredUser, который относится к типу User,  будет сформирована строка:

registered_user id:int nickname:string = User

А число будет -366049798

Функции

Все функции для протокола будут асинхронными. Первым аргументом является request - объект типа aiohttp.web.Request, который мы используем для доступа к общим ресурсам.

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

from aiohttp.web import Request
from mtpylon.crypto import AuthKey
from mtpylon.exceptions import RpcCallError
from mtpylon.contextvars import auth_key_var

from users.utils import login_user, remember_user
from ..constructors import User, RegisteredUser


async def login(request: Request, nickname: str, password: str) -> User:
   try:
       user = await login_user(nickname, password)
   except ValueError as e:
       raise RpcCallError(error_code=401, error_message=str(e))

   auth_key: AuthKey = auth_key_var.get()
   await remember_user(user, auth_key)

   return RegisteredUser(id=user.id, nickname=nickname)

Для идентификации функции используется номер, который считается по алгоритму crc32 из строки вида:

function_name arg_1:ArgType arg_2:ArgType2 … arg_name_n=ArgTypeN = ResType

Контекстные переменные

Для каждого WebSocket соединения определены несколько контекстных переменных:

  • Схема

    Создав типы данных и функции для бэкенда, можем объединить их в схему, которую будет использовать клиент. Для создания схемы используется класс mtpylon.Schema

    from mtpylon import Schema
    
    
    from .constructors import (
       Bool,
       User,
       TodoList,
       TodoListsResult,
       Task,
       TaskList
    )
    from .functions import (
       register,
       login,
       get_me,
       create_todo_list,
       get_todo_lists,
       get_single_todo_list,
       remove_todo_list,
       create_task,
       get_task_list,
       edit_task_title,
       set_as_completed,
       set_as_uncompleted,
       remove_task,
    )
    
    mtpylon_schema = Schema(
       constructors=[
           Bool,
           User,
           TodoList,
           TodoListsResult,
           Task,
           TaskList
       ],
       functions=[
           register,
           login,
           get_me,
           create_todo_list,
           get_todo_lists,
           get_single_todo_list,
           remove_todo_list,
           create_task,
           get_task_list,
           edit_task_title,
           set_as_completed,
           set_as_uncompleted,
           remove_task,
       ]

    Создав схему, мы можем ее сериализовать либо в TL-программу, либо в JSON, используя для этого сериализации mtpylon.serializers.to_tl_program() или mtpylon.serializers.to_json(). Зная как сериализовать и десериализовать сообщения, мы можем начать общение между клиентом и сервером.

    Криптографическая(Авторизационная) часть.

    Данная часть хорошо описана в официальной документации телеграмма

    Есть два варианта сообщений: незашифрованные и зашифрованные сообщения. Незашифрованные используются для создания ключа с использованием алгоритма Деффи-Хельмана.

    Подробное описание для клиента можно почитать здесь Структура незашифрованного сообщения:

    Со стороны сервера нам необходимо:

    1. Cгенерировать и хранить публичный/приватный RSA-ключ.

    2. Хранить авторизационный ключ(2048 bit). У авторизационного ключа имеется 64х битный идентификатор(auth_id), поэтому достаточно просто организовать key-value хранилище.

    В своей реализации я храню их в памяти, используя класс  mtproto.crypto.auth_key_manager.AuthKeyManager, но реализуя протокол mtproto.crypto.auth_key_manager.AuthKeyManagerProtocol,можно работать с любой бд. К примеру, я использовал postgresql, где хранил id как BigInteger, а сам ключ как BLOB.

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

    Структура зашифрованного сообщения:

    Схема шифрования данных:

    Исходя из структуры и схемы, нам надо хранить идентификатор сессии клиента и соль.

    Идентификатор сессии session_id

    Идентификатор сессии создается пользователем, поэтому пара auth_key_id и session_id будет уникальна, тогда как session_id у разных пользователей может повторяться. 

    session_id - 64 битное значение, поэтому его легко хранить. mtproto.sessions.sessions_storage_protocol.SessionSсtorageProtocol должен быть реализован для хранения в различных бд, или можно использовать mtproto.sessions.in_memory_session_storage.InMemorySessionStorage для хранения информации о сессии в памяти. Для информирования о создании или удалении сессий используется паттерн observer.

    Соль(salt).

    Соль - 64 битное случайное число, меняющееся в определенный период времени. Используется для предотвращения атак повторного воспроизведения. Сервер должен генерировать числа на текущее время и на некоторое будущее. Сгенерированная соль и авторизационный ключ создают уникальную пару.  Для генерации и работы с солью был создан mtpylon.salts.server_salt_manager.ServerSaltManager, который хранит все данные в памяти. Для реализации кастомного протокола нужно реализовать mtpylon.salts.server_satl_manager_protocol.ServerSaltManagerProtocol.

     Подтверждения (acknowledgements)

    Каждое сообщение имеет свой id. И пара Авторизационный ключ + id сообщения будет уникальна. Сервер хранит сообщение и пересылает пользователю до тех пор, пока не придет подтверждение, что оно было обработано или сессия пользователя не была уничтожена. Для хранения сообщений в оперативной памяти реализован класс mtpylon.acknowledgment_store.inmemory_acknowledgment_store.InmemoryAcknowledgmentStore .

    Транспортная часть.

    Зашифрованное сообщение передается по протоколу websocket. Согласно протоколу MTProto, каждое передаваемое сообщение должно быть обфусцировано. Каждое сообщение перед отправкой “оборачивается” в intermediate блок  и проходит обязательную обфускацию

    Библиотека mtpylon и пример использования.

    Реализовав все части протокола MTProto, получилось создать библиотеку mtpylon, с помощью которой можем создать собственное API.

    Пример приложения:

    1. создадим функцию, генерирующую rsa-ключи:

    rsa_keys.py

    from typing import List
    import rsa  # type: ignore
    from mtpylon.crypto import KeyPair  # type: ignore
    
    
    def get_rsa_keys(count: int = 2) -> List[KeyPair]:
        rsa_list = [
            rsa.newkeys(nbits=2048)
            for _ in range(count)
        ]
    
        return [
            KeyPair(
                public=public,
                private=private
            ) for (public, private) in rsa_list
        ]

    2. Определим возвращаемый тип, функцию и саму схему:

    schema.py

    import random
    from dataclasses import dataclass
    
    from aiohttp import web
    
    from mtpylon import Schema
    
    
    @dataclass
    class Reply:
        rand_id: int
        content: str
    
        class Meta:
            name = 'reply'
            order = ('rand_id', 'content')
    
    
    async def echo(request: web.Request, content: str) -> Reply:
        return Reply(
            rand_id=random.randint(1, 100),
            content=content
        )
    
    
    schema = Schema(constructors=[Reply], functions=[echo])

    3. Настроим aiohttp для работы с протоколом:

    web.py

    import sys
    import logging
    
    from aiohttp import web
    import aiohttp_cors
    
    from mtpylon.configuration import configure_app
    
    from schema import schema as app_schema
    from rsa_keys import get_rsa_keys
    
    
    if __name__ == '__main__':
        app = web.Application()
        configure_app(
            app,
            app_schema,
            {
                'rsa_manager': {
                    'params': {
                        'rsa_keys': get_rsa_keys()  # вернем сгенерированные ключи
                    }
                },
                'pub_keys_path': '/pub-keys',  # url по которому доступны публичные части rsa-ключей
                'schema_path': '/schema',  # url по которому доступна схема в json формате
            }
        )
    
        cors = aiohttp_cors.setup(
            app,
            defaults={
                '*': aiohttp_cors.ResourceOptions(
                    allow_credentials=True,
                    expose_headers="*",
                    allow_headers="*",
                )
            }
        )
    
        for route in list(app.router.routes()):
            cors.add(route)
    
        web.run_app(app, port=8081)

    4. Запустим приложение:

    python ./web.py 

    5. Со стороны клиента используем библиотеку zagram

    const { MTProto, methodFromSchema } = zagram;
    
    const WS_URL = 'ws://localhost:8081/ws';
    const PUB_KEYS_URL = 'http://localhost:8081/pub-keys';
    const SCHEMA_URL = 'http://localhost:8081/schema';
    
    
    function initConnection(schema, pems) {
      return new Promise((resolve, reject) => {
        const connection = new MTProto(WS_URL, schema, pems);
    
        connection.addEventListener('statusChanged', (e) => {
           if (e.status === 'AUTH_KEY_CREATED') {
             resolve([connection, schema]);
           } else {
             reject(e.status);
           }
         });
    
         connection.init();
      });  
     
    }
    
    
    
    Promise
      .all([
        fetch(SCHEMA_URL).then(r => r.json()),
        fetch(PUB_KEYS_URL).then(r => r.json()),
      ])
      .then(([schema, pems]) => initConnection(schema, pems))
      .then(([connection, schema]) => {
        const rpc = methodFromSchema(schema, 'echo', {'content': 'hello world'});
        return connection.request(rpc);
      })
      .then(console.log); 

    Итог:

    Спасибо Telegram, что разнообразили мои будни, заставили меня поглубже разобраться в криптографии и решать нетривиальные задачи. Буду рад, если кому-нибудь эти библиотеки будут полезны или вдохновят на собственную реализацию протокола MTProto.

    Mtpylon:

    исходный код: https://github.com/Zapix/mtpylon

    документация: https://mtpylon.readthedocs.io/en/latest/

    Zagram:

    исходный код: https://github.com/Zapix/zagram

    Примеры приложений:

    1. echo-server https://github.com/Zapix/echo-server

    2. ToDo-list: https://github.com/Zapix/mtpylon-todo-list

Источник: https://habr.com/ru/post/589959/


Интересные статьи

Интересные статьи

"Нoвый фopмaт пpилoжeний для мaгaзинa Windows pacпpocтpaняeтcя в ycтaнoвoчных фaйлaх .Appx и .AppxBundle и включaет в ceбя лишь caмy пpoгpaммy, eё имя и oпиcaниe. Эти пaкeты пoзвoляют paзpaбoтчикaм нe...
Автор не несет ответственности за материал предоставленный в данной статье, все что вы прочитали или увидели - мне приснилось. Инструменты написанные в процессе анализа п...
Статистика для Москвы в сценарии «люди стараются сидеть по домам, нет авиасообщения» — к ноябрю модель показывает 5 миллионов переболевших. Это ограниченный прогноз на основе неполных данных, ...
В обновлении «Сидней» Битрикс выпустил новый продукт в составе Битрикс24: магазины. Теперь в любом портале можно создать не только лендинг или многостраничный сайт, но даже интернет-магазин. С корзино...
Привет, Хабр! Предлагаю вашему вниманию перевод статьи основателя сервиса Meetspaceapp Nick Gauthier «Building Minimal Docker Containers for Go Applications». Время чтения: 6 минут Существу...