STP-D — защищенный протокол для игр на D
Идея с шифрованием из коробки реализовалась буквально за неделю: я решил портировать 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. Брокер копирует все сообщения, созданные в динамической памяти, в арену, а потом удаляет оригинал. Объекты-слушатели читают сообщения из арены, а в начале следующего цикла она очищается. Соответственно, получается контракт — любые данные событий, доступные по указателю, считаются короткоживущими, то есть, доступными для чтения только в процессе обработки события в пределах одного шага игрового цикла. Для сетевых пакетов это вполне нормально — любые данные, полученные по сети, нужно сразу читать в стековые переменные или куда-то копировать, если они нужны в течение долгого времени.