В уже далеком 2019 Telegram объявил конкурс на создание веб-версии своего мессенджера, в котором мне удалось поучаствовать. По итогу у меня осталась библиотека, которая может работать с API Telegram по протоколу MTProto. Полученный опыт вдохновил меня реализовать протокол MTProto для бэкенда. Разработку вел на python, так как я этот язык хорошо знаю и был уверен, что смогу на нем реализовать свою идею. Для удобства использовал библиотеку aiohttp для соединения по web-socket’у, а для описания структур использовал typings и dataclass.
Для описания конструктора на языке 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
Следующими аргументами могут быть либо базовые, либо пользовательские типы. Все аргументы функции являются обязательными.Результат функции должен иметь пользовательский тип.
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
Структура протокола MTProto:
Протокол MTProto можно поделить на три независимые части:- Верхнеуровневая часть (API и запросы к нему) - описывает, как вызовы и ответы API могут быть переведены в двоичный код.
- Криптографическая (Авторизационная) часть - описывает, как сообщения будут зашифрованы/расшифрованы.
- Транспортная часть - описывает, как зашифрованное сообщение будет передано по одному из протоколов: (HTTP, HTTPS, WS, WSS, TCP, UDP)
Верхнеуровневая часть API - Схема(schema)
Схема телеграма описывает какие есть доступные типы и функции для API. Благодаря схеме клиент знает как сериализовать/десериализовать входящие и исходящие сообщения. Схема описывается на языке TL или с помощью JSON.Базовые типы:
При описании конструкторов, типов, и функций доступных в схеме используются базовые типы int, long, doubles, bytes, string. Для них описано как они будут сериализованы и десериализованы:- int - 32 битное знаковое целое, для дампа которого используется little-endian порядок байтов
- long - 64 битное знаковое целое, для дампа которого используется little-endian порядок байтов
- double - 64 битное число с плавающей запятой
- bytes - последовательность байтов определенной длины:
- Если длина последовательности меньше либо равна 253, то в первый байт мы записываем длину последовательности, а в конце добавляем нулевые байты так, что общая длина последовательности делилась на 4.
- Если длина последовательности больше 253, то в первый байт мы записываем число 254, дальше идут 3 байта в порядке little-endian, описывающие длину последовательности, сама последовательность, ну и в конце добавляем нулевые байты, чтобы общая длина делилась на 4.
- string - строка состоящая из UTF-8 символов, для сериализации используется тот же алгоритм, что и для bytes.
Конструкторы и типы данных:
С помощью базовых типов можно описать комплексные структуры. Самая простая комплексная структура - конструктор. Атрибутами конструктора могут быть базовые или пользовательские типы данных.Для описания конструктора на языке 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(). Зная как сериализовать и десериализовать сообщения, мы можем начать общение между клиентом и сервером.
Криптографическая(Авторизационная) часть.
Данная часть хорошо описана в официальной документации телеграмма.
Есть два варианта сообщений: незашифрованные и зашифрованные сообщения. Незашифрованные используются для создания ключа с использованием алгоритма Деффи-Хельмана.
Подробное описание для клиента можно почитать здесь Структура незашифрованного сообщения:
Со стороны сервера нам необходимо:- Cгенерировать и хранить публичный/приватный RSA-ключ.
- Хранить авторизационный ключ(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
Примеры приложений:- echo-server https://github.com/Zapix/echo-server
- ToDo-list: https://github.com/Zapix/mtpylon-todo-list
Создание API на основе протокола MTProto
В уже далеком 2019 Telegram объявил конкурс на создание веб-версии своего мессенджера, в котором мне удалось поучаствовать. По итогу у меня осталась библиотека, которая может работать с API...
habr.com