Раньше я мог спокойно общаться с друзьями, используя Discord, делиться игровым процессом в Factorio или RimWorld, или смотреть стримы прохождения какой-нибудь новинки (или старой игры) в Steam. Со временем Discord перестал работать без дополнительных манипуляций, а с появлением MAX накрылась и нормальная голосовая связь в Telegram (случайное совпадение). Можно использовать различные обходные решения, но я решил пойти другим путем и сделать что-то свое — чисто ради интереса и эксперимента.
Вместе с другом мы начали изучать различные мессенджеры на основе децентрализованных протоколов. Протестировали Jami, Tox и несколько других решений. Проверяли базовые функции: текстовую переписку, голосовые звонки и демонстрацию экрана. К сожалению, каждое из протестированных решений имело критические проблемы: вылеты приложения, разрывы соединений или проблемы с аудиоустройствами. Для нас это критично, поскольку звонки обычно длятся по несколько часов.
Так родилась идея написать собственный мессенджер, где базовые функции будут работать стабильно. Это был отличный повод погрузиться в низкоуровневые детали: поработать с криптографией на эллиптических кривых, разобраться в тонкостях UDP-коммуникации и решить проблемы с потерей пакетов. В этой статье я расскажу о технических решениях, которые были реализованы в проекте, и о том, с какими сложностями пришлось столкнуться.
Архитектура решения
Проект реализован на Rust и разделен на несколько взаимосвязанных модулей:
ntied-crypto — криптографический модуль, реализующий операции с ключами p256, цифровые подписи ECDSA и симметричное шифрование AES-GCM
ntied-transport — низкоуровневый транспортный протокол поверх UDP с поддержкой NAT traversal
ntied-server — минималистичный сервер для преодоления NAT, хранящий информацию о публичных адресах клиентов
ntied — основное приложение с бизнес-логикой, UI на iced и работой с аудио через cpal
Для хранения данных используется SQLite с шифрованием через sqlcipher. Хеширование паролей реализовано с помощью Argon2id. Высокоуровневый протокол обмена сообщениями построен на serde и bincode.
Peer-to-peer протокол
Транспортный протокол работает поверх UDP и обеспечивает надежную доставку данных между двумя участниками (на самом деле, надежность доставки достигается в более высокоуровневых абстракциях). Протокол состоит из нескольких типов пакетов, которые можно разделить на две категории.
Структура пакетов
Все пакеты транспортного уровня представлены следующим enum:
pub enum Packet {
Handshake(HandshakePacket),
HandshakeAck(HandshakeAckPacket),
Encrypted(EncryptedPacket),
}
После установления соединения все пакеты шифруются и передаются внутри EncryptedPacket. Расшифрованное содержимое представляет собой:
pub enum DecryptedPacket {
Heartbeat(HeartbeatPacket),
HeartbeatAck(HeartbeatPacket),
Data(DataPacket),
Rotate(RotatePacket),
RotateAck(RotatePacket),
}
Незашифрованные пакеты для handshake
Первая категория — пакеты для установления соединения. Они передаются в открытом виде, так как шифрование ещё не установлено.
Структура HandshakePacket:
pub struct HandshakePacket {
pub source_id: u32,
pub peer_address: Address,
pub address: Address,
pub public_key: Vec<u8>,
pub ephemeral_public_key: Vec<u8>,
pub signature: Vec<u8>,
}
Handshake — инициирует соединение, содержит source_id (уникальный идентификатор сессии), адреса обоих участников, долговременный публичный ключ, эфемерный публичный ключ для ECDH и цифровую подпись эфемерного ключа. Подпись позволяет убедиться, что эфемерный ключ действительно создан владельцем долговременного ключа.
HandshakeAck — подтверждение соединения, содержит аналогичный набор полей плюс target_id от инициатора. После обмена этими пакетами обе стороны вычисляют общий секрет через ECDH и переходят к защищенной коммуникации.
Зашифрованные пакеты
Вторая категория — пакеты внутри установленного соединения. Все они шифруются с помощью AES-GCM:
Heartbeat/HeartbeatAck — пакеты для проверки активности соединения. Отправляются каждые 10 секунд. Если в течение 30 секунд не получено ни одного пакета от собеседника, соединение считается оборванным.
Data — полезная нагрузка, содержит произвольные данные приложения (сообщения, аудио, метаданные звонков).
Rotate/RotateAck — механизм для ротации ключей шифрования. Этот механизм обеспечивает Perfect Forward Secrecy: даже если долговременный ключ будет скомпрометирован в будущем, расшифровать старые сообщения не получится.
Система эпох и ротация ключей
Для защиты от компрометации используется система эпох (epoch). Каждая эпоха — это период времени, в течение которого используется определенная пара эфемерных ключей для шифрования. При смене эпохи происходит следующее:
Генерируется новая пара эфемерных ключей
Публичный ключ подписывается долговременным ключом
Отправляется пакет Rotate с новым публичным ключом и подписью
Вторая сторона проверяет подпись, вычисляет новый общий секрет
Отправляет RotateAck со своим новым эфемерным ключом
Обе стороны переключаются на новую эпоху
Номер эпохи занимает один байт, что позволяет обрабатывать до 127 эпох (верхние биты зарезервированы для идентификации типа пакета). После достижения максимального значения счетчик циклически возвращается к единице.
Важная деталь реализации: в любой момент времени поддерживаются две эпохи одновременно — текущая и следующая. Это позволяет корректно обрабатывать пакеты, которые были отправлены в момент переключения и могли прийти с задержкой.
Преодоление NAT
Прямое UDP-соединение между клиентами за NAT невозможно без предварительного обмена адресами. Для этого реализован вспомогательный сервер с публичным IP-адресом. Клиент регистрируется на сервере, отправляя свой публичный ключ. Сервер сохраняет соответствие между публичным ключом и UDP-адресом клиента.
Когда клиент A хочет соединиться с клиентом B, он отправляет запрос на сервер с публичным ключом B. Сервер одновременно отправляет обоим клиентам информацию об адресах и публичных ключах друг друга. После этого клиенты инициируют Handshake напрямую. Благодаря тому, что оба клиента одновременно отправляют UDP-пакеты друг другу, NAT создает соответствующие маппинги портов, и пакеты начинают проходить.
Сервер также поддерживает heartbeat-механизм для обновления информации об адресах клиентов, поскольку NAT-маппинги могут меняться со временем.
Интерфейс транспортного уровня
Для работы с транспортом реализованы два основных типа: Transport и Connection.
Интерфейс Transport предоставляет методы для создания и принятия соединений:
impl Transport {
pub async fn bind(
addr: impl ToSocketAddrs,
address: Address,
private_key: PrivateKey,
server_addr: SocketAddr,
) -> Result<Self, Error>;
pub async fn connect(&self, address: Address) -> Result<Connection, Error>;
pub async fn accept(&self) -> Result<Connection, Error>;
pub fn local_addr(&self) -> SocketAddr;
pub fn address(&self) -> Address;
}
Метод bind создает UDP-сокет и регистрируется на вспомогательном сервере. Метод connect инициирует соединение с удаленным участником по его адресу (публичному ключу), а accept ожидает входящие соединения.
После установления соединения работа происходит через Connection:
impl Connection {
pub async fn send(&self, data: impl Into<Vec<u8>>) -> Result<(), Error>;
pub async fn recv(&self) -> Result<Vec<u8>, Error>;
pub fn peer_addr(&self) -> SocketAddr;
pub fn peer_address(&self) -> &Address;
pub fn peer_public_key(&self) -> &PublicKey;
}
Методы send и recv предоставляют простой интерфейс для обмена данными. Внутри send автоматически происходит шифрование с генерацией nonce, а recv возвращает уже расшифрованные данные. Соединение поддерживается автоматически через heartbeat-пакеты и закрывается при таймауте или явном drop.
Хранилище данных
Система хранения построена на двух файлах: meta.json и data.db. Такая архитектура обусловлена требованиями безопасности и производительности.
Для работы с базой данных используется библиотека tokio-sqlite — это собственная разработка, предоставляющая асинхронный интерфейс к SQLite. Важной особенностью является то, что при подключении зависимости пришлось явно прокинуть features bundled и bundled-sqlcipher (о которых раньше не приходилось задумываться), чтобы получить поддержку sqlcipher:
tokio-sqlite = { version = "0.*", features = ["bundled", "bundled-sqlcipher"] }
Без этих features библиотека не сможет работать с зашифрованными базами данных, так как стандартный SQLite не включает поддержку шифрования.
Файл meta.json
Этот файл содержит параметры для получения ключа шифрования базы данных:
{
"hash": {
"type": "Argon2id",
"m_cost": 65536,
"t_cost": 3,
"p_cost": 2
},
"salt": "..."
}
Здесь хранятся параметры функции Argon2id и случайная соль. Argon2id — это функция хеширования паролей, устойчивая к атакам на GPU и ASIC. Параметры настроены так, чтобы обеспечить баланс между безопасностью и производительностью: операция занимает около 64 МБ памяти и выполняется примерно секунду на современном процессоре.
Файл data.db
Это зашифрованная база данных SQLite с использованием sqlcipher. Ключ шифрования получается путем применения Argon2id к паролю пользователя с параметрами из meta.json. SQLCipher шифрует каждую страницу базы данных отдельно, используя AES-256 в режиме CBC.
Инициализация и разблокировка базы данных
При открытии базы данных происходит следующий процесс:
Чтение meta.json и извлечение параметров Argon2id
Вычисление ключа шифрования из пароля
Подключение к data.db через tokio-sqlite
Разблокировка базы через PRAGMA-команду
Разблокировка базы выполняется SQL-запросом с передачей ключа в шестнадцатеричном формате:
let hex_key = hex::encode(key).to_uppercase();
let pragma_key = format!("PRAGMA key = \"x'{}'\"", hex_key);
connection.query(pragma_key, Vec::<Value>::new()).await?;
SQLCipher использует этот ключ для расшифровки страниц базы данных по мере их чтения. Если ключ неверный, запросы к базе будут возвращать ошибки о поврежденной базе данных.
При смене пароля используется аналогичная команда PRAGMA rekey:
let hex_key = hex::encode(new_key).to_uppercase();
let pragma_key = format!("PRAGMA rekey = \"x'{}'\"", hex_key);
connection.query(pragma_key, Vec::<Value>::new()).await?;
Команда rekeyперешифровывает все страницы базы данных новым ключом. Это происходит атомарно и не требует временных копий. Такая двухфайловая архитектура (meta.json + data.db) позволяет изменять параметры хеширования пароля независимо от содержимого базы данных.
Проверка пароля
Для быстрой проверки правильности пароля при открытии базы хранится SHA-256 хеш пароля в памяти после успешной аутентификации. Это позволяет проверять пароль при смене без повторного обращения к базе данных.
Структура базы данных включает таблицы для контактов, сообщений и конфигурации. Все операции с базой выполняются асинхронно через tokio-sqlite.
Работа с чатами
Система обмена сообщениями построена на трех типах пакетов высокого уровня, которые сериализуются с помощью bincode и передаются внутри Data-пакетов транспортного уровня:
Message — содержит message_id (UUID v7), log_id (последовательный номер для разрешения конфликтов) и тип сообщения. Сейчас поддерживается только текст, но архитектура позволяет легко добавить файлы, изображения или голосовые сообщения.
MessageAck — подтверждение получения сообщения. Отправитель помечает сообщение как доставленное только после получения этого пакета.
Conflict — уведомление о конфликте log_id. В peer-to-peer системе оба участника могут одновременно отправить сообщения с одинаковым log_id. При обнаружении такой коллизии отправляется Conflict-пакет, и обе стороны пересогласовывают нумерацию.
Разрешение конфликтов
Каждое сообщение в чате имеет два идентификатора: глобальный message_id и локальный log_id. UUID v7 используется как глобальный идентификатор, поскольку он включает временную метку и гарантирует уникальность. log_id нужен для упорядочивания сообщений в пользовательском интерфейсе.
При возникновении конфликта log_id сообщение с более поздним временем создания получает приоритет и сохраняет свой номер, а другое сообщение пронумеровывается заново. Это гарантирует консистентность истории чата на обеих сторонах.
Хранение сообщений
В базе данных создается таблица message со следующей структурой: id (автоинкремент), contact_id (внешний ключ на контакт), message_id (UUID), log_id (порядковый номер), incoming (флаг входящего сообщения), kind (тип сообщения), content (содержимое), create_time (время создания), receive_time (время получения), read_time (время прочтения).
Индексы построены по парам (contact_id, log_id) и (contact_id, create_time) для быстрого поиска и сортировки сообщений.
Интерфейс ChatManager
Для управления чатами реализован ChatManager, предоставляющий следующий API:
impl ChatManager {
pub async fn new(
storage: Arc<TokioMutex<Storage>>,
contact_manager: Arc<ContactManager>,
) -> Result<Self, anyhow::Error>;
pub async fn list_contact_chats(&self) -> Vec<ChatHandle>;
pub async fn add_contact_chat(
&self,
address: Address,
public_key: PublicKey,
name: String,
local_name: Option<String>,
) -> Result<ChatHandle, anyhow::Error>;
pub async fn get_contact_chat(&self, address: Address) -> Option<ChatHandle>;
pub async fn remove_contact_chat(&self, address: Address) -> Result<(), anyhow::Error>;
}
При инициализации ChatManager загружает все контакты из базы данных и создает для каждого ChatHandle — объект для работы с конкретным чатом. Каждый ChatHandle содержит ссылку на хранилище и автоматически синхронизирует сообщения при отправке и получении.
Метод add_contact_chat создает новый контакт в базе данных и устанавливает соединение через ContactManager. После этого можно отправлять сообщения и получать уведомления о входящих через механизм listener'ов.
Работа со звонками
Голосовые звонки реализованы поверх установленного peer-to-peer соединения. Протокол звонков состоит из нескольких типов пакетов:
Start — инициация звонка, содержит call_id (UUID) для идентификации сессии.
Accept/Reject — ответ на входящий звонок.
End — завершение активного звонка любой из сторон.
CodecOffer/CodecAnswer — согласование параметров аудиокодека. Инициатор отправляет список поддерживаемых кодеков и предпочитаемые параметры, принимающая сторона выбирает подходящий вариант из списка.
AudioData — пакет со сжатым аудио, содержит call_id, порядковый номер (sequence), временную метку (timestamp), тип кодека, количество каналов и закодированные данные.
Работа с аудиоустройствами
Для захвата и воспроизведения звука используется библиотека cpal, которая предоставляет кроссплатформенный доступ к аудиоустройствам. После принятия звонка создается AudioState — структура, содержащая:
Encoder и Decoder для выбранного кодека
CaptureStream для захвата аудио с микрофона
PlaybackStream для воспроизведения на динамики
Несколько асинхронных задач для обработки потоков
Захваченный звук помещается в кольцевой буфер, откуда его читает задача кодирования. Закодированные фреймы отправляются через сеть. Принятые фреймы декодируются и помещаются в выходной кольцевой буфер для воспроизведения.
Управление состоянием звонка
CallManager следит за тем, чтобы одновременно был активен только один звонок. Попытка инициировать второй звонок возвращает ошибку. При завершении звонка автоматически останавливаются все задачи обработки аудио и освобождаются ресурсы.
Для каждого активного соединения с контактом запускается задача опроса пакетов (polling task). Она читает Data-пакеты из транспортного соединения, десериализует их и маршрутизирует в соответствующий обработчик в зависимости от типа (чат или звонок).
Кодеки
Архитектура поддерживает различные аудиокодеки. Согласование происходит через обмен CodecOffer/CodecAnswer пакетами. Каждая сторона сообщает о поддерживаемых кодеках и их параметрах (частота дискретизации, битрейт). Выбирается кодек, поддерживаемый обеими сторонами, с предпочтением более эффективных вариантов.
Реализована поддержка переключения аудиоустройств во время звонка. При смене устройства создается новый CaptureStream или PlaybackStream, а старые потоки корректно останавливаются. Также доступно управление громкостью и отключение микрофона.
Интерфейс CallManager
Управление звонками осуществляется через CallManager:
impl CallManager {
pub fn new(contact_manager: Arc<ContactManager>) -> Arc<Self>;
pub async fn start_call(&self, address: Address) -> Result<CallHandle, anyhow::Error>;
pub async fn accept_call(&self, address: Address) -> Result<(), anyhow::Error>;
pub async fn reject_call(&self, address: Address) -> Result<(), anyhow::Error>;
pub async fn end_call(&self, address: Address) -> Result<(), anyhow::Error>;
pub async fn get_current_call(&self) -> Option<CallHandle>;
pub async fn is_in_call(&self) -> bool;
pub async fn is_muted(&self) -> Result<bool, anyhow::Error>;
pub async fn toggle_mute(&self) -> Result<bool, anyhow::Error>;
pub async fn get_current_input_device(&self) -> Option<String>;
pub async fn get_current_output_device(&self) -> Option<String>;
pub async fn switch_input_device(
&self,
device_name: Option<String>,
) -> Result<(), anyhow::Error>;
pub async fn switch_output_device(
&self,
device_name: Option<String>,
) -> Result<(), anyhow::Error>;
pub async fn set_capture_volume(&self, volume: f32) -> Result<(), anyhow::Error>;
pub async fn set_playback_volume(&self, volume: f32) -> Result<(), anyhow::Error>;
pub async fn get_capture_volume(&self) -> Result<f32, anyhow::Error>;
pub async fn get_playback_volume(&self) -> Result<f32, anyhow::Error>;
}
CallManager гарантирует, что одновременно активен только один звонок. При попытке начать второй звонок метод start_call вернет ошибку. После принятия звонка через accept_call автоматически инициализируется AudioState с захватом микрофона и воспроизведением на динамики.
Методы для управления устройствами позволяют переключаться между разными микрофонами и динамиками прямо во время звонка, что критично для работы с USB-гарнитурами или Bluetooth-устройствами.
Результаты и планы
В результате получился работающий прототип мессенджера с функциями текстового чата и голосовых звонков. Основные задачи выполнены: соединения стабильны, звонки длятся часами без разрывов, аудиоустройства работают корректно.
Из технически интересных моментов стоит отметить комбинацию долговременных и эфемерных ключей для обеспечения Perfect Forward Secrecy, систему разрешения конфликтов в peer-to-peer чатах и архитектуру с разделением на независимые модули.
Чему удалось научиться
Проект оказался отличной возможностью углубиться в темы, с которыми раньше работал только на поверхностном уровне, либо вообще не работал.
Криптография на эллиптических кривых. Реализация ECDH для обмена ключами и ECDSA для цифровых подписей заставила разобраться в математике за этими алгоритмами. Понятно, что с нуля ничего не надо реализовывать, однако почитать несколько статей про то, как работает криптография на эллиптических кривых было очень увлекательным занятием.
Особенности UDP-коммуникации. Работа напрямую с UDP показала разницу между теорией и практикой. Пакеты могут приходить не в том порядке, могут теряться, могут дублироваться. Первые попытки тестирования голосовых звонков выявили серьезные проблемы: пакеты терялись при высокой нагрузке, что приводило к артефактам в звуке. Пришлось добавить буферизацию, оптимизировать размер пакетов и реализовать восстановление при потерях.
NAT traversal. Оказалось, что заставить два компьютера за NAT соединиться напрямую — нетривиальная задача. Как же классно было на практике добиться того, что два компьютера в нескольких сотнях километров друг от друга смогли соединиться друг с другом без необходимости в использовании белых IP-адресов.
Проект показал, что Rust отлично подходит для подобных задач: система типов помогает избежать многих классов ошибок, производительность позволяет обрабатывать аудиопотоки в реальном времени, а экосистема предоставляет качественные библиотеки для криптографии, сетевого взаимодействия и работы с аудио.
Опыт использования LLM
При разработке проекта активно использовались большие языковые модели: Claude Sonnet 4 и Claude Opus 4 на начальных этапах, а затем Claude Sonnet 4.5 и GPT-5 после их выхода.
Прототипирование с LLM. Опыт показал, что для быстрого прототипирования это очень удобные инструменты. Можно быстро получить работающий код для новой функции или модуля, посмотреть как это может выглядеть, протестировать идею. Однако качество сгенерированного кода и наличие большого числа багов приводило к тому, что большую часть кода приходилось переписывать с нуля, используя результат LLM как пример референсной реализации.
Критичные модули. В реализации транспортного протокола и криптографического модуля от LLM-реализации практически ничего не осталось. Эти части системы требовали особой тщательности: ошибка в обработке пакетов или криптографии могла привести к серьезным проблемам с безопасностью и стабильностью. Код был полностью переписан вручную с акцентом на корректность, безопасность и чистоту архитектуры.
UI-код. А вот интерфейс на iced почти полностью реализован LLM, и я до сих пор не осилил взяться переписать некоторые части адекватно. По коду разница между реализацией интерфейса и реализацией протокола сильно заметна:
Транспортный протокол качественно разбит по модулям и сущностям, везде используется single source of truth для состояния, чистое разделение ответственности. Функции компактные, каждая выполняет определенную роль.
UI-код выглядит совсем иначе: раздутые функции обработки событий, проблемы с синхронизацией состояний между компонентами интерфейса. Все известные баги были закостылены вручную, но архитектурные проблемы остались. Это хороший пример того, что LLM может быстро сгенерировать работающий код, но качество и поддерживаемость такого кода оставляют желать лучшего.
Выводы. LLM отлично подходит как инструмент для ускорения разработки и получения идей, но критичный код требует ручного пересмотра и рефакторинга. Особенно это касается модулей, где важна безопасность, производительность или долгосрочная поддерживаемость.
Планы развития
Текущая реализация покрывает базовые потребности, но есть несколько направлений для дальнейшего развития:
Улучшение безопасности протокола. Сейчас цифровая подпись проверяет только эфемерный ключ. Планирую переделать механизм так, чтобы подпись покрывала весь handshake-пакет целиком, включая адреса и идентификаторы. Это защитит от потенциальных атак с подменой полей пакета.
Демонстрация экрана. Архитектура уже поддерживает передачу видеокадров через VideoDataPacket, но требуется реализация захвата экрана и эффективное сжатие. Планируется использовать дифференциальное кодирование для передачи только изменившихся областей экрана.
Групповые звонки. В более отдаленной перспективе хотелось бы добавить поддержку конференций. Это потребует серьезного пересмотра архитектуры: вместо прямого соединения между двумя участниками нужна будет mesh-топология или выделенный узел для микширования аудиопотоков.
Исходный код проекта: github.com/udovin/ntied
MAXH0
Ну что сказать... Если это не фейк и будет опубликовано под вменяемой лицензией, то ДАЙТЕ ДВА
wilcot Автор
В принципе, а почему бы и сейчас не открыть. Точно не фейк, можно проверить