Привет, Хабр! Сидя обычным субботним днем, я понял, что мне скучно. Хотелось какого-нибудь интересного pet-проекта. Идея пришла быстро: почему бы не написать простой мессенджер? Но как только я об этом подумал, тут же встал вопрос: а как защищать данные пользователей?

Можно было бы взять готовый TLS, но это же pet-проект! Где еще можно легально и с удовольствием "городить велосипеды"? Так я и решил создать свой собственный протокол шифрования. А чтобы было интереснее, поставил себе жесткое условие: уложиться в два выходных дня.

В этой статье я разберу, что у меня получилось, как работает мой протокол ObscuraProto, какие криптографические решения я выбрал и почему.

Что под капотом?

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

1. Безопасный обмен ключами при установке соединения.

2. Быстрое шифрование самих данных.

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

Библиотеку я буду писать на C++ и в качестве фундамента я выбрал проверенную временем библиотеку libsodium, которая предоставляет надежные и высокоуровневые криптографические примитивы.

Шаг 1: Рукопожатие (Handshake)

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

Представим это в виде диалога между клиентом и сервером:

Установка соединения между клиентом и сервером
Установка соединения между клиентом и сервером

А теперь подробнее:

1. Client Hello. Клиент генерирует эфемерную (временную) пару ключей на эллиптических кривых (по протоколу ECDH) и отправляет свою публичную часть серверу.

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

3. Завершение. Клиент, получив ответ, проверяет подпись с помощью известного ему публичного ключа сервера. Если все в порядке, обе стороны, используя свой приватный эфемерный ключ и чужой публичный, вычисляют абсолютно идентичный общий секрет S.

Этот секрет S мы не используем напрямую. Вместо этого мы пропускаем его через Key Derivation Function (KDF), чтобы получить из него два разных симметричных ключа: один для шифрования сообщений от клиента к серверу, другой — в обратную сторону.

На C++ с помощью моей библиотеки это выглядит так:

// 1. Клиент инициирует рукопожатие и отправляет client_hello серверу
auto client_hello = client_session.client_initiate_handshake();
// 2. Сервер получает client_hello, отвечает и сам завершает рукопожатие
auto server_hello = server_session.server_respond_to_handshake(client_hello);
assert(server_session.is_handshake_complete());
// 3. Клиент получает server_hello и тоже завершает рукопожатие
client_session.client_finalize_handshake(server_hello);
assert(client_session.is_handshake_complete());

После этого между клиентом и сервером установлен защищенный канал.

Шаг 2: Упаковка и передача данных

Теперь, когда у нас есть сессионные ключи, можно передавать данные. Каждое сообщение оборачивается в защитный "конверт".

Структура этого конверта выглядит так:

[Nonce (12 байт)] + [Счетчик (8 байт)] + [Зашифрованные данные (переменная длина)] + [Auth Tag (16 байт)]

Nonce — случайное число, уникальное для каждого сообщения. Нужно для корректной работы шифра.

Счетчик — номер сообщения. Это наша защита от атак повторного воспроизведения (replay attacks), если приходит сообщение с счетчиком меньшим либо равным тому, которе уже было получено, оно игнорируется.

Зашифрованные данные — полезная нагрузка.

Auth Tag — это "аутентификационная метка", которую генерирует шифр. Она защищает целостность и подлинность всего пакета. Если злоумышленник изменит хоть один бит в Nonce, счетчике или данных, метка станет невалидной.

Внутри зашифрованных данных лежит полезная нагрузка (payload) со своей структурой:

[Код операции (2 байта)] + [Параметры операции]

Параметры сериализуются простым методом Length-Prefix: перед каждым полем идет его длина.

[Длина поля (2 байта)] + [Значение поля]

Например, для операции "логин" (пусть ее код будет 0x1001) с username="test" и password="p@ss" полезная нагрузка до шифрования будет выглядеть так:

[0x1001] [0x0004] [test] [0x0004] [p@ss]

Вот как это выглядит в коде:

// Клиент создает и шифрует полезную нагрузку
ObscuraProto::Payload client_payload;
client_payload.op_code = 0x1001;
client_payload.add_param("test");
client_payload.add_param("p@ss");

// Получаем вектор байт, готовый к отправке по сети
ObscuraProto::EncryptedPacket packet_to_send = client_session.encrypt_payload(client_payload);

// Сервер получает пакет и расшифровывает его
try {
  ObscuraProto::Payload decrypted_payload = server_session.decrypt_packet(packet_to_send);
  // ... дальше парсим параметры и используем данные
} catch (const ObscuraProto::RuntimeError& e) {
  // Если метка неверна или счетчик некорректен — ловим исключение.
  // Сообщение нужно немедленно отбросить.
}

Ныряем вглубь: почему именно эти алгоритмы?

Выбор конкретных алгоритмов — ключевой момент в разработке протокола. Просто сказать "я использую эллиптические кривые" недостаточно. Дьявол, как всегда, в деталях. Вот на чем я остановился и, что более важно, — почему.

Асимметричная пара: X25519 и Ed25519
Что это? Это два алгоритма, построенные на одной и той же эллиптической кривой Curve25519. X25519 используется для обмена ключами по алгоритму Диффи-Хеллмана (ECDH), а Ed25519 — для цифровых подписей.
Почему они? Curve25519 была спроектирована с упором на производительность и безопасность. Ключевое преимущество — устойчивость к атакам по времени (timing attacks). Реализации алгоритмов на этой кривой написаны так, что они выполняются за постоянное время, вне зависимости от секретных данных. Это лишает злоумышленника возможности анализировать микроскопические задержки в ответах сервера, чтобы получить информацию о секретном ключе. Это современный, быстрый и де-факто стандарт для новых протоколов (используется в TLS 1.3, Signal, и др.).

Симметричный шифр: ChaCha20-Poly1305
Что это? Это потоковый шифр (ChaCha20) в связке с кодом аутентификации (Poly1305). Вместе они образуют AEAD-шифр (Authenticated Encryption with Associated Data). Он не только шифрует данные, но и одновременно обеспечивает их целостность и подлинность.
Почему он, а не AES-GCM? AES — это стандарт, и он очень быстр, если у процессора есть аппаратная поддержка (инструкции AES-NI). Однако на устройствах без нее (старые десктопы, многие мобильные процессоры, микроконтроллеры) AES работает значительно медленнее. ChaCha20, в свою очередь, показывает превосходную производительность на любом "железе", так как не зависит от аппаратного ускорения. Выбирая ChaCha20, я делал ставку на универсальность и стабильно высокую скорость на широком спектре устройств.

Функция вывода ключа (KDF)

Что это? В статье я упомянул, что общий секрет S, полученный через ECDH, «пропускается через KDF». Зачем? Дело в том, что S — это результат математической операции, и использовать его «как есть» в качестве ключа небезопасно. KDF (Key Derivation Function) — это криптографический «миксер». Он берет исходный, недостаточно случайный секрет S и превращает его в один или несколько криптографически стойких ключей. В моем случае libsodium использует функцию crypto_kx_client_session_keys (и серверный аналог), которая внутри себя реализует KDF на основе хеш-функции BLAKE2b, генерируя два независимых ключа: rx (для приема) и tx (для передачи).

Защита от Replay-атак: счетчик как Associated Data

Что это? Для защиты от атак повторного воспроизведения я использую счетчик сообщений. Он не шифруется, но включается в "дополнительные аутентифицируемые данные" (Associated Data). Это значит, что код аутентификации Auth Tag вычисляется не только для зашифрованного текста, но и для связки (ciphertext, counter). Если злоумышленник попытается изменить счетчик в перехваченном пакете, Auth Tag станет невалидным. Если он отправит старый пакет повторно, наша логика if (received_counter <= last_counter) его отбросит. Это простой и надежный способ защиты.

Заглянем в код: как сервер отвечает на рукопожатие

Теория — это хорошо, но код всегда нагляднее. Давайте посмотрим на самую "сердцевину" логики сервера — функцию server_respond_to_handshake из session.cpp. Я добавил комментарии, чтобы пояснить каждый шаг.

// session.cpp

Session::ServerHello Session::server_respond_to_handshake(const ClientHello& client_hello) {

if (role_ != Role::SERVER) {
  throw LogicError("Only servers can respond to a handshake.");
}

// 1. Выбираем совместимую версию протокола.
// В текущей реализации мы просто ищем поддержку V1_0.
// В будущем здесь будет логика выбора наилучшей общей версии.

bool version_supported = false;
for (const auto& v : client_hello.supported_versions) {
  if (v == Versions::V1_0) {
    version_supported = true;
    break;
  }
}

if (!version_supported) {
  throw RuntimeError("Client does not support a compatible version.");
}

// 2. Генерируем собственную эфемерную (одноразовую) пару ключей для этого сеанса.
// Это ключевой элемент для обеспечения Perfect Forward Secrecy.

ephemeral_kx_kp_ = std::make_unique<KeyPair>(Crypto::generate_kx_keypair());

// 3. Подписываем свой эфемерный публичный ключ своим долгосрочным приватным ключом.
// Это доказательство для клиента, что мы — именно тот сервер, за кого себя выдаем.

Signature signature = Crypto::sign(ephemeral_kx_kp_->publicKey.data, server_sign_key_.privateKey);

// 4. Вычисляем общие сессионные ключи (rx и tx).
// Для этого используем свой эфемерный приватный ключ и публичный ключ клиента.
// Внутри этой функции libsodium выполняет операцию ECDH и применяет KDF.

session_keys_ = std::make_unique<Crypto::SessionKeys>(
  Crypto::server_compute_session_keys(*ephemeral_kx_kp_, client_hello.ephemeral_pk)
);

// 5. Формируем ответное сообщение ServerHello.
ServerHello hello;
hello.selected_version = Versions::V1_0;
hello.ephemeral_pk = ephemeral_kx_kp_->publicKey; // Наш эфемерный публичный ключ
hello.signature = signature;                      // И подпись для него

// С этого момента сервер готов к обмену зашифрованными данными.
handshake_complete_ = true;

return hello;
}

Этот фрагмент кода наглядно демонстрирует весь процесс на стороне сервера: от проверки совместимости до генерации финальных ключей шифрования. (Увидеть больше можно в github репозитории проекта)

Итоги и планы на будущее

Так что в итоге? За два выходных дня мне действительно удалось с нуля написать и отладить библиотеку на C++, реализующую описанный протокол. Она успешно устанавливает соединение, шифрует и расшифровывает трафик.

Что не успел? Изначально я проектировал и механизм возобновления сессий (session resumption), чтобы при повторном подключении не проводить полный хендшейк заново. Это сильно ускоряет дело. Однако реализовать эту часть за выходные я физически не успел, так что она остается в планах.

Планы на будущее:

1. Реализовать возобновление сессий. Это важная оптимизация, которую нужно доделать.

2. Создать удобную обертку. Текущий API довольно низкоуровневый и требует от разработчика аккуратного управления состоянием. Хочется сделать более высокоуровневый интерфейс, которым можно будет пользоваться как в программах на C++, так и в программах использующих другие ЯП.

3. Написать тот самый мессенджер! В конце концов, все это затевалось именно ради него.

Это был очень увлекательный и познавательный опыт. Если вам интересно посмотреть на исходный код, добро пожаловать на GitHub!

Ссылка на GitHub-репозиторий проекта

Буду рад вашим комментариям, советам и критике.

Комментарии (0)