Перейти к основному содержимому

Сжатие и шифрование

Соединение открывается сырым: без сжатия и без шифра. Оба слоя включаются посреди сессии, каждый своим действием кода приложения, и оба относятся к транспорту в целом - к обоим направлениям сразу.

Сжатие

Порог сжатия сообщает сервер пакетом LoginCompressPacket. Код приложения переносит число в CompressionThreshold у MinecraftClient (то же свойство есть у MinecraftConnection, MinecraftClient его только пробрасывает). Значение отрицательное - сжатие выключено, это же и начальное состояние соединения. Новое значение действует с ближайшего кадра, а не с текущего.

Порог решает судьбу каждого исходящего пакета отдельно. Пакет короче порога уходит как есть, но в кадре появляется дополнительный VarInt перед телом - 0, означающий «не сжат». Пакет не короче порога сжимается, и в этом же месте кадра стоит VarInt с исходным (несжатым) размером. На входящей стороне ноль в этом поле - сигнал развернуть пакет как есть, отличное от нуля значение - сколько байт распаковать. В протоколе этот формат описан на странице With compression.

Движок сжатия - libdeflate, из пакета McProtoNet.Native. Управляющий код вызывает его через DllImport: libdeflate_zlib_compress, libdeflate_zlib_decompress и парные функции alloc/free для хэндла компрессора и декомпрессора. Нативный вызов вместо встроенного ZLibStream взят ради скорости, формат от этого не меняется: на проводе всё тот же поток zlib.

Хэндлы компрессора и декомпрессора не создаются на каждый пакет: LibDeflateCache держит по одному на поток ([ThreadStatic]) и переиспользует их на все кадры этого потока. Уровень сжатия компрессора зашит в коде - 4 из диапазона libdeflate 0-12.

Шифрование

Шифр - AES/CFB8: тот же режим, что описан в протоколе на странице Encryption. Общий секрет, которым стороны обменялись через EncryptionRequestPacket и EncryptionResponsePacket, служит сразу и ключом, и вектором инициализации - PacketCipher.SharedSecretLength требует ровно 16 байт, это ключ AES-128.

PacketCipher.CreateEncryptor и CreateDecryptor собирают по шифру на каждое направление - MinecraftConnection.EnableEncryption создаёт оба сразу и отдаёт их читателю и писателю кадров. Шифр включается с ближайшего следующего кадра в обе стороны и назад не выключается: EnableEncryption второй раз на том же соединении бросает исключение.

Аппаратные пути

Конкретную реализацию PacketCipher выбирает статическая фабрика по поддержке процессора, в порядке проверки:

if (AesCfb8HardwareCipher.IsSupported)
return new AesCfb8HardwareCipher(sharedSecret, sharedSecret, encrypting);

if (AesCfb8ArmCipher.IsSupported)
return new AesCfb8ArmCipher(sharedSecret, sharedSecret, encrypting);

return new AesCfb8Cipher(sharedSecret, sharedSecret, encrypting);

AesCfb8HardwareCipher - интринсики x86 AES-NI поверх SSE2/SSSE3/SSE4.1. AesCfb8ArmCipher - AES и AdvSimd (NEON) на ARM64, с собственным расширением ключа AES-128 (AesKeySchedule), потому что платформенного Aes.Create() для этого пути не хватает. Когда не поддержано ни то ни другое, в дело идёт AesCfb8Cipher - обёртка над платформенным Aes с EncryptCfb/DecryptCfb и feedbackSizeInBits: 8. Обе интринсик-реализации при расшифровке дополнительно распараллеливают блоки по 16 байт за проход, а не идут байт за байтом, как того требует CFB8 сам по себе.

Порядок на потоке

Снаружи, ближе к сокету, - шифр. Внутри - кадр целиком, включая varint длины и, если он есть, конверт сжатия. При записи BufferedPacketWriter сперва достраивает кадр (длина, при необходимости - сжатие), и только затем шифрует получившиеся байты на месте. При чтении наоборот: сырые байты из сокета сначала проходят через шифр, и лишь потом код разбирает в них длину кадра и, если нужно, распаковывает тело.

Что делает приложение

Сжатие и шифрование не переключаются сами - оба включает код приложения, по одной строке на каждый:

client.CompressionThreshold = packet.Threshold;
client.EnableEncryption(secret);

Всё остальное - выбор движка, аппаратного пути, формат кадра и порядок применения на потоке - остаётся внутри транспорта.

Дальше