Идея с шифрованием из коробки реализовалась буквально за неделю: я решил портировать Monocypher на D, что вылилось в создание пакета Minicrypto и расширения dagon:security, о которых сегодня и расскажу. Вкратце — это позволит играм на Dagon (да и вообще практически любым приложениям) использовать защищенное UDP-соединение клиента и сервера, причем без зависимости от тяжелых протоколов типа DTLS или SRTP.

Шифрование всегда идет рука об руку с компьютерной паранойей, поэтому сразу оговорюсь: хоть я и постарался сделать порт на 100% идентичным оригиналу (буквально построчно), он пока не тестировался в реальных задачах, поэтому использовать Minicrypto для неигровых задач, критичных к абсолютной безопасности, я пока не рекомендую. Гарантий, как обычно с таким ПО, никаких.

Что поддерживает Minicrypto:

  • Аутентифицированное шифрование с присоединенными данными (AEAD) по алгоритму ChaCha20-Poly1305. Это симметричный шифр ChaCha20 и имитозащита Poly1305. Они позволяют не только зашифровать данные, но и обеспечить их подлинность и целостность: принимающая сторона может быть уверена, что пакет был софрмирован передающей стороной и не был перехвачен и подменен (или модифицирован) злоумышленником;
  • Диффи-Хеллман на эллиптических кривых Curve25519 — для обмена ключами и выработки общего секрета;
  • Криптографическая хеш-функция BLAKE2b;
  • Функция хеширования паролей Argon2;
  • Схема цифровой подписи EdDSA для защиты от MITM-атак при рукопожатии сервера и клиента;
  • Криптостойкий генератор псевдослучайных чисел (CSPRNG).

Все функции @nogc, библиотека может быть скомпилирована в режиме BetterC. Как и в оригинальном Monocypher, динамическая память не используется, а промежуточные секретные данные на стеке уничтожаются после вывода в пользовательские буферы.

На базе Minicrypto и ENet я спроектировал протокол, который назвал STP-D (Secure Transport Protocol for D).

Шаг 1. Рукопожатие

Используется протокол Диффи-Хеллмана на эллиптических кривых (X25519).

1. При старте соединения обе стороны (клиент и сервер) генерируют эфемерные (сессионные) 32-байтные ключи X25519 и случайные 32-байтные одноразовые числа: serverPublicKey, serverRandom, clientPublicKey, clientRandom;

2. Клиент отправляет серверу clientPublicKey и clientRandom.

3. Сервер формирует строку clientPublicKey + clientRandom + serverPublicKey + serverRandom и подписывает ее своим долговременным закрытым ключом EdDSA/Ed25519 для защиты от MITM-атак. serverPublicKey и serverRandom вместе с подписью отправляются клиенту;

4. Клиент формирует точно такую же строку и проверяет по ней подпись с помощью заранее известного ему публичного ключа EdDSA/Ed25519 сервера. Если проверка не проходит, сервер считается поддельным, и рукопожатие останавливается. Благодаря тому, что подписываются все эфемерные данные целиком, исключается риск того, что данные клиента будут подменены человеком посередине при первом запросе — строка сервера и строка клиента не совпадут;

5. Обе стороны вычисляют 32-байтный общий секрет через X25519.

Протокол опирается на принцип «закрепленного сертификата»: ключ для проверки электронной подписи сервера создается один раз, встраивается в клиент и не меняется (либо меняется при обновлении софта по другим каналам). Для генерации пары ключей предусмотрена утилита eddsa-keygen.

Шаг 2. Деривация ключей

Общий секрет сам по себе не используется для шифрования, а используется обеими сторонами как источник для деривации при помощи хеш-функции BLAKE2b с ключом (keyed mode).

1. Вычисляется общий 64-байтный мастер-ключ из общего секрета и эфемерных данных обеих сторон:

context =
    "stp:v1:master:" +
    clientPublicKey +
    clientRandom +
    serverPublicKey +
    serverRandom;
masterKey = BLAKE2b_keyed(key=sharedSecret, context, 64);

2. Мастер-ключ используется для вычисления общих 32-байтных сеансовых ключей шифрования:

  • ClientWriteKey = BLAKE2b_keyed(key=masterKey, "client_enc", 32) — ключ для шифрования пакетов от клиента к серверу;
  • ServerWriteKey = BLAKE2b_keyed(key=masterKey, "server_enc", 32) — ключ для шифрования пакетов от сервера к клиенту.

Использование отдельных ClientWriteKey и ServerWriteKey исключает повторное использование одного и того же ключа в обоих направлениях передачи. Дополнительные строковые метки гарантируют, что ключи независимы, хотя вычислены из одного секрета.

Шаг 3. Отправка и прием сообщений

После успешной проверки подписи и генерации сеансовых ключей соединение переходит в режим передачи сообщений. Для шифрования сообщений используется ChaCha20-Poly1305 IETF. Текст сообщения шифруется ключом отправляющей стороны (ClientWriteKey или ServerWriteKey).

Пакет STP состоит из:

  • Незашифрованного заголовка, который пока состоит только из nonce (неповторяющегося числа). Заголовок пакета передается в ChaCha20-Poly1305 как Additional Authenticated Data (AAD), благодаря чему он защищается от подделки, хотя и не шифруется;
  • 16-байтового аутентификационного кода (MAC);
  • Шифротекста.

Nonce — это 12-байтное число, используемое для шифрования ChaCha20-Poly1305. Оно включает 64-битный монотонный счетчик (дополненный нулями до 96 бит), который возрастает с каждым отправленным пакетом. На принимающей стороне ведется такой же счетчик принятых сообщений. Если nonce из входящего пакета не совпадает с ожидаемым, сообщение отбрасывается еще до расшифровки.

Если сообщение не прошло аутентификацию ChaCha20-Poly1305, оно отбрасывается как недействительное.

API

Фишка STP в том, что протокол полностью прозрачный — он встраивается поверх ENet как специальный асинхронный сервис NetworkClient, работающий в своем потоке. Благодаря этому пользовательский API вообще никак не затрагивает шифрование — код игры работает только с открытым текстом, сетевые запросы и процедуры шифрования не блокируют основной цикл, и игра идет без задержек.

Вся система элегантно уместилась в расширение dagon:network, о котором я писал ранее. Единственная проблема, которую пришлось решить модификацией ядра движка — выделение и освобождение памяти под расшифрованный открытый текст. Дело в том, что асинхронный сервис в Dagon не может сам управлять временем жизни своих сообщений, он их отдает брокеру. Это в точности как с почтовыми отправлениями — положив письмо в ящик, ты уже не знаешь, когда его прочтут, и не можешь повлиять на его судьбу. Поэтому сообщения создаются в динамической памяти. Но возлагать их удаление на объекты-слушатели тоже нельзя, ведь они получают одни и те же события независимо — система не предполагает, что пользовательские объекты обязаны что-то делать с сообщениями, она только дает им возможность их прочитать. В итоге я решил это при помощи короткоживущей арены EventManager.tmpHeap. Брокер копирует все сообщения, созданные в динамической памяти, в арену, а потом удаляет оригинал. Объекты-слушатели читают сообщения из арены, а в начале следующего цикла она очищается. Соответственно, получается контракт — любые данные событий, доступные по указателю, считаются короткоживущими, то есть, доступными для чтения только в процессе обработки события в пределах одного шага игрового цикла. Для сетевых пакетов это вполне нормально — любые данные, полученные по сети, нужно сразу читать в стековые переменные или куда-то копировать, если они нужны в течение долгого времени.

Оставить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *