From d28be4eaf24a890f7ef110a51181a3d806a6acca Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Fri, 21 Jan 2022 23:05:21 +0000 Subject: [PATCH] Quick and dirty backport of encryption, preserving BC --- composer.json | 2 + composer.lock | 78 ++++++- src/pocketmine/Player.php | 57 ++++- src/pocketmine/Server.php | 3 + src/pocketmine/network/mcpe/JwtException.php | 28 +++ src/pocketmine/network/mcpe/JwtUtils.php | 211 ++++++++++++++++++ .../mcpe/PlayerNetworkSessionAdapter.php | 2 +- .../network/mcpe/RakLibInterface.php | 22 +- .../mcpe/encryption/DecryptionException.php | 28 +++ .../mcpe/encryption/EncryptionContext.php | 119 ++++++++++ .../mcpe/encryption/EncryptionUtils.php | 68 ++++++ .../mcpe/encryption/PrepareEncryptionTask.php | 96 ++++++++ src/pocketmine/resources/pocketmine.yml | 3 + 13 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 src/pocketmine/network/mcpe/JwtException.php create mode 100644 src/pocketmine/network/mcpe/JwtUtils.php create mode 100644 src/pocketmine/network/mcpe/encryption/DecryptionException.php create mode 100644 src/pocketmine/network/mcpe/encryption/EncryptionContext.php create mode 100644 src/pocketmine/network/mcpe/encryption/EncryptionUtils.php create mode 100644 src/pocketmine/network/mcpe/encryption/PrepareEncryptionTask.php diff --git a/composer.json b/composer.json index 4c8b68e82..5f5bebb3f 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "php": "^8.0", "php-64bit": "*", "ext-chunkutils2": "^0.3.1", + "ext-crypto": "^0.3.1", "ext-ctype": "*", "ext-curl": "*", "ext-date": "*", @@ -27,6 +28,7 @@ "ext-zlib": ">=1.2.11", "composer-runtime-api": "^2.0", "adhocore/json-comment": "^1.1", + "fgrosse/phpasn1": "^2.3", "pocketmine/binaryutils": "^0.1.9", "pocketmine/callback-validator": "^1.0.2", "pocketmine/classloader": "^0.1.0", diff --git a/composer.lock b/composer.lock index f98e641d5..5711dc22f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9fe058549206174b6c62b2a41685083c", + "content-hash": "4ee772232d0936f6f9eda5d54ec2462d", "packages": [ { "name": "adhocore/json-comment", @@ -61,6 +61,81 @@ ], "time": "2021-04-09T03:06:06+00:00" }, + { + "name": "fgrosse/phpasn1", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/fgrosse/PHPASN1.git", + "reference": "eef488991d53e58e60c9554b09b1201ca5ba9296" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fgrosse/PHPASN1/zipball/eef488991d53e58e60c9554b09b1201ca5ba9296", + "reference": "eef488991d53e58e60c9554b09b1201ca5ba9296", + "shasum": "" + }, + "require": { + "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "~2.0", + "phpunit/phpunit": "^6.3 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-bcmath": "BCmath is the fallback extension for big integer calculations", + "ext-curl": "For loading OID information from the web if they have not bee defined statically", + "ext-gmp": "GMP is the preferred extension for big integer calculations", + "phpseclib/bcmath_compat": "BCmath polyfill for servers where neither GMP nor BCmath is available" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "FG\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Friedrich Große", + "email": "friedrich.grosse@gmail.com", + "homepage": "https://github.com/FGrosse", + "role": "Author" + }, + { + "name": "All contributors", + "homepage": "https://github.com/FGrosse/PHPASN1/contributors" + } + ], + "description": "A PHP Framework that allows you to encode and decode arbitrary ASN.1 structures using the ITU-T X.690 Encoding Rules.", + "homepage": "https://github.com/FGrosse/PHPASN1", + "keywords": [ + "DER", + "asn.1", + "asn1", + "ber", + "binary", + "decoding", + "encoding", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/fgrosse/PHPASN1/issues", + "source": "https://github.com/fgrosse/PHPASN1/tree/v2.4.0" + }, + "time": "2021-12-11T12:41:06+00:00" + }, { "name": "pocketmine/binaryutils", "version": "0.1.13", @@ -2762,6 +2837,7 @@ "php": "^8.0", "php-64bit": "*", "ext-chunkutils2": "^0.3.1", + "ext-crypto": "^0.3.1", "ext-ctype": "*", "ext-curl": "*", "ext-date": "*", diff --git a/src/pocketmine/Player.php b/src/pocketmine/Player.php index 1bf11b1d6..ac8d4ba35 100644 --- a/src/pocketmine/Player.php +++ b/src/pocketmine/Player.php @@ -102,6 +102,8 @@ use pocketmine\nbt\tag\DoubleTag; use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\tag\StringTag; use pocketmine\network\mcpe\convert\ItemTypeDictionary; +use pocketmine\network\mcpe\encryption\EncryptionContext; +use pocketmine\network\mcpe\encryption\PrepareEncryptionTask; use pocketmine\network\mcpe\PlayerNetworkSessionAdapter; use pocketmine\network\mcpe\protocol\ActorEventPacket; use pocketmine\network\mcpe\protocol\AdventureSettingsPacket; @@ -139,6 +141,7 @@ use pocketmine\network\mcpe\protocol\ResourcePackDataInfoPacket; use pocketmine\network\mcpe\protocol\ResourcePacksInfoPacket; use pocketmine\network\mcpe\protocol\ResourcePackStackPacket; use pocketmine\network\mcpe\protocol\RespawnPacket; +use pocketmine\network\mcpe\protocol\ServerToClientHandshakePacket; use pocketmine\network\mcpe\protocol\SetPlayerGameTypePacket; use pocketmine\network\mcpe\protocol\SetSpawnPositionPacket; use pocketmine\network\mcpe\protocol\SetTitlePacket; @@ -184,6 +187,7 @@ use pocketmine\tile\ItemFrame; use pocketmine\tile\Spawnable; use pocketmine\tile\Tile; use pocketmine\timings\Timings; +use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\TextFormat; use pocketmine\utils\UUID; use function abs; @@ -285,6 +289,8 @@ class Player extends Human implements CommandSender, ChunkLoader, IPlayer{ /** @var DataPacket[] */ private $batchedPackets = []; + private ?EncryptionContext $cipher = null; + /** * @var int * Last measurement of player's latency in milliseconds. @@ -300,6 +306,8 @@ class Player extends Human implements CommandSender, ChunkLoader, IPlayer{ /** @var bool */ private $seenLoginPacket = false; /** @var bool */ + private $awaitingEncryptionHandshake = false; + /** @var bool */ private $resourcePacksDone = false; /** @var bool */ @@ -2073,9 +2081,49 @@ class Player extends Human implements CommandSender, ChunkLoader, IPlayer{ $this->xuid = $xuid; } - //TODO: encryption + $identityPublicKey = base64_decode($packet->identityPublicKey, true); + if($identityPublicKey === false){ + //if this is invalid it should have borked VerifyLoginTask + throw new AssumptionFailedError("We should never have reached here if the key is invalid"); + } + + if(EncryptionContext::$ENABLED){ + $this->server->getAsyncPool()->submitTask(new PrepareEncryptionTask( + $identityPublicKey, + function(string $encryptionKey, string $handshakeJwt) : void{ + if(!$this->isConnected()){ + return; + } + + $pk = new ServerToClientHandshakePacket(); + $pk->jwt = $handshakeJwt; + $this->sendDataPacket($pk, false, true); //make sure this gets sent before encryption is enabled + + $this->awaitingEncryptionHandshake = true; + + $this->cipher = EncryptionContext::fakeGCM($encryptionKey); + + $this->server->getLogger()->debug("Enabled encryption for " . $this->username); + } + )); + }else{ + $this->processLogin(); + } + } + + /** + * @internal + */ + public function onEncryptionHandshake() : bool{ + if(!$this->awaitingEncryptionHandshake){ + return false; + } + $this->awaitingEncryptionHandshake = false; + + $this->server->getLogger()->debug("Encryption handshake completed for " . $this->username); $this->processLogin(); + return true; } /** @@ -3434,6 +3482,13 @@ class Player extends Human implements CommandSender, ChunkLoader, IPlayer{ } } + /** + * @internal + */ + public function getCipher() : ?EncryptionContext{ + return $this->cipher; + } + /** * @return bool|int */ diff --git a/src/pocketmine/Server.php b/src/pocketmine/Server.php index 48e598a8a..6cb0b3566 100644 --- a/src/pocketmine/Server.php +++ b/src/pocketmine/Server.php @@ -71,6 +71,7 @@ use pocketmine\nbt\tag\ShortTag; use pocketmine\nbt\tag\StringTag; use pocketmine\network\AdvancedSourceInterface; use pocketmine\network\CompressBatchedTask; +use pocketmine\network\mcpe\encryption\EncryptionContext; use pocketmine\network\mcpe\protocol\BatchPacket; use pocketmine\network\mcpe\protocol\DataPacket; use pocketmine\network\mcpe\protocol\PlayerListPacket; @@ -1406,6 +1407,8 @@ class Server{ } $this->networkCompressionAsync = (bool) $this->getProperty("network.async-compression", true); + EncryptionContext::$ENABLED = (bool) $this->getProperty("network.enable-encryption", true); + $this->doTitleTick = ((bool) $this->getProperty("console.title-tick", true)) && Terminal::hasFormattingCodes(); $consoleSender = new ConsoleCommandSender(); diff --git a/src/pocketmine/network/mcpe/JwtException.php b/src/pocketmine/network/mcpe/JwtException.php new file mode 100644 index 000000000..14a3c21a2 --- /dev/null +++ b/src/pocketmine/network/mcpe/JwtException.php @@ -0,0 +1,28 @@ + gmp_strval(gmp_import($str, 1, GMP_BIG_ENDIAN | GMP_MSW_FIRST), 10); + + $sequence = new Sequence( + new Integer($convert($rString)), + new Integer($convert($sString)) + ); + + $v = openssl_verify( + $header . '.' . $body, + $sequence->getBinary(), + $signingKey, + OPENSSL_ALGO_SHA384 + ); + switch($v){ + case 0: return false; + case 1: return true; + case -1: throw new JwtException("Error verifying JWT signature: " . openssl_error_string()); + default: throw new AssumptionFailedError("openssl_verify() should only return -1, 0 or 1"); + } + } + + /** + * @phpstan-param array $header + * @phpstan-param array $claims + */ + public static function create(array $header, array $claims, \OpenSSLAsymmetricKey $signingKey) : string{ + $jwtBody = JwtUtils::b64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)) . "." . JwtUtils::b64UrlEncode(json_encode($claims, JSON_THROW_ON_ERROR)); + + openssl_sign( + $jwtBody, + $rawDerSig, + $signingKey, + OPENSSL_ALGO_SHA384 + ); + + try{ + $asnObject = Sequence::fromBinary($rawDerSig); + }catch(ParserException $e){ + throw new AssumptionFailedError("Failed to parse OpenSSL signature: " . $e->getMessage(), 0, $e); + } + if(count($asnObject) !== 2){ + throw new AssumptionFailedError("OpenSSL produced invalid signature, expected exactly 2 parts"); + } + [$r, $s] = [$asnObject[0], $asnObject[1]]; + if(!($r instanceof Integer) || !($s instanceof Integer)){ + throw new AssumptionFailedError("OpenSSL produced invalid signature, expected 2 INTEGER parts"); + } + $rString = $r->getContent(); + $sString = $s->getContent(); + + $toBinary = fn($str) => str_pad( + gmp_export(gmp_init($str, 10), 1, GMP_BIG_ENDIAN | GMP_MSW_FIRST), + 48, + "\x00", + STR_PAD_LEFT + ); + $jwtSig = JwtUtils::b64UrlEncode($toBinary($rString) . $toBinary($sString)); + + return "$jwtBody.$jwtSig"; + } + + public static function b64UrlEncode(string $str) : string{ + return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); + } + + public static function b64UrlDecode(string $str) : string{ + if(($len = strlen($str) % 4) !== 0){ + $str .= str_repeat('=', 4 - $len); + } + $decoded = base64_decode(strtr($str, '-_', '+/'), true); + if($decoded === false){ + throw new JwtException("Malformed base64url encoded payload could not be decoded"); + } + return $decoded; + } + + public static function emitDerPublicKey(\OpenSSLAsymmetricKey $opensslKey) : string{ + $details = openssl_pkey_get_details($opensslKey); + if($details === false){ + throw new AssumptionFailedError("Failed to get details from OpenSSL key resource"); + } + + /** @var string $pemKey */ + $pemKey = $details['key']; + if(preg_match("@^-----BEGIN[A-Z\d ]+PUBLIC KEY-----\n([A-Za-z\d+/\n]+)\n-----END[A-Z\d ]+PUBLIC KEY-----\n$@", $pemKey, $matches) === 1){ + $derKey = base64_decode(str_replace("\n", "", $matches[1]), true); + if($derKey !== false){ + return $derKey; + } + } + throw new AssumptionFailedError("OpenSSL resource contains invalid public key"); + } + + public static function parseDerPublicKey(string $derKey) : \OpenSSLAsymmetricKey{ + $signingKeyOpenSSL = openssl_pkey_get_public(sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", base64_encode($derKey))); + if($signingKeyOpenSSL === false){ + throw new JwtException("OpenSSL failed to parse key: " . openssl_error_string()); + } + return $signingKeyOpenSSL; + } +} diff --git a/src/pocketmine/network/mcpe/PlayerNetworkSessionAdapter.php b/src/pocketmine/network/mcpe/PlayerNetworkSessionAdapter.php index b300d3142..a56fb2ba2 100644 --- a/src/pocketmine/network/mcpe/PlayerNetworkSessionAdapter.php +++ b/src/pocketmine/network/mcpe/PlayerNetworkSessionAdapter.php @@ -118,7 +118,7 @@ class PlayerNetworkSessionAdapter extends NetworkSession{ } public function handleClientToServerHandshake(ClientToServerHandshakePacket $packet) : bool{ - return false; //TODO + return $this->player->onEncryptionHandshake(); } public function handleResourcePackClientResponse(ResourcePackClientResponsePacket $packet) : bool{ diff --git a/src/pocketmine/network/mcpe/RakLibInterface.php b/src/pocketmine/network/mcpe/RakLibInterface.php index 4097340b4..16bfcea37 100644 --- a/src/pocketmine/network/mcpe/RakLibInterface.php +++ b/src/pocketmine/network/mcpe/RakLibInterface.php @@ -45,6 +45,7 @@ use function get_class; use function implode; use function rtrim; use function spl_object_hash; +use function substr; use function unserialize; use const PTHREADS_INHERIT_CONSTANTS; @@ -55,6 +56,8 @@ class RakLibInterface implements ServerInstance, AdvancedSourceInterface{ */ private const MCPE_RAKNET_PROTOCOL_VERSION = 10; + private const MCPE_RAKNET_PACKET_ID = "\xfe"; + /** @var Server */ private $server; @@ -163,9 +166,18 @@ class RakLibInterface implements ServerInstance, AdvancedSourceInterface{ //get this now for blocking in case the player was closed before the exception was raised $player = $this->players[$identifier]; $address = $player->getAddress(); + try{ if($packet->buffer !== ""){ - $pk = new BatchPacket($packet->buffer); + if($packet->buffer[0] !== self::MCPE_RAKNET_PACKET_ID){ + throw new \UnexpectedValueException("Unexpected non-FE packet"); + } + + $cipher = $player->getCipher(); + $buffer = substr($packet->buffer, 1); + $buffer = $cipher !== null ? $cipher->decrypt($buffer) : $buffer; + + $pk = new BatchPacket(self::MCPE_RAKNET_PACKET_ID . $buffer); $player->handleDataPacket($pk); } }catch(\Throwable $e){ @@ -245,17 +257,21 @@ class RakLibInterface implements ServerInstance, AdvancedSourceInterface{ } if($packet instanceof BatchPacket){ + $cipher = $player->getCipher(); + $rawBuffer = substr($packet->buffer, 1); + $buffer = self::MCPE_RAKNET_PACKET_ID . ($cipher !== null ? $cipher->encrypt($rawBuffer) : $rawBuffer); + if($needACK){ $pk = new EncapsulatedPacket(); $pk->identifierACK = $this->identifiersACK[$identifier]++; - $pk->buffer = $packet->buffer; + $pk->buffer = $buffer; $pk->reliability = PacketReliability::RELIABLE_ORDERED; $pk->orderChannel = 0; }else{ if(!isset($packet->__encapsulatedPacket)){ $packet->__encapsulatedPacket = new CachedEncapsulatedPacket; $packet->__encapsulatedPacket->identifierACK = null; - $packet->__encapsulatedPacket->buffer = $packet->buffer; + $packet->__encapsulatedPacket->buffer = $buffer; $packet->__encapsulatedPacket->reliability = PacketReliability::RELIABLE_ORDERED; $packet->__encapsulatedPacket->orderChannel = 0; } diff --git a/src/pocketmine/network/mcpe/encryption/DecryptionException.php b/src/pocketmine/network/mcpe/encryption/DecryptionException.php new file mode 100644 index 000000000..1a5e7e690 --- /dev/null +++ b/src/pocketmine/network/mcpe/encryption/DecryptionException.php @@ -0,0 +1,28 @@ +key = $encryptionKey; + + $this->decryptCipher = new Cipher($algorithm); + $this->decryptCipher->decryptInit($this->key, $iv); + + $this->encryptCipher = new Cipher($algorithm); + $this->encryptCipher->encryptInit($this->key, $iv); + } + + /** + * Returns an EncryptionContext suitable for decrypting Minecraft packets from 1.16.200 and up. + * + * MCPE uses GCM, but without the auth tag, which defeats the whole purpose of using GCM. + * GCM is just a wrapper around CTR which adds the auth tag, so CTR can replace GCM for this case. + * However, since GCM passes only the first 12 bytes of the IV followed by 0002, we must do the same for + * compatibility with MCPE. + * In PM, we could skip this and just use GCM directly (since we use OpenSSL), but this way is more portable, and + * better for developers who come digging in the PM code looking for answers. + */ + public static function fakeGCM(string $encryptionKey) : self{ + return new EncryptionContext( + $encryptionKey, + "AES-256-CTR", + substr($encryptionKey, 0, 12) . "\x00\x00\x00\x02" + ); + } + + public static function cfb8(string $encryptionKey) : self{ + return new EncryptionContext( + $encryptionKey, + "AES-256-CFB8", + substr($encryptionKey, 0, 16) + ); + } + + /** + * @throws DecryptionException + */ + public function decrypt(string $encrypted) : string{ + if(strlen($encrypted) < 9){ + throw new DecryptionException("Payload is too short"); + } + $decrypted = $this->decryptCipher->decryptUpdate($encrypted); + $payload = substr($decrypted, 0, -8); + + $packetCounter = $this->decryptCounter++; + + if(($expected = $this->calculateChecksum($packetCounter, $payload)) !== ($actual = substr($decrypted, -8))){ + throw new DecryptionException("Encrypted packet $packetCounter has invalid checksum (expected " . bin2hex($expected) . ", got " . bin2hex($actual) . ")"); + } + + return $payload; + } + + public function encrypt(string $payload) : string{ + return $this->encryptCipher->encryptUpdate($payload . $this->calculateChecksum($this->encryptCounter++, $payload)); + } + + private function calculateChecksum(int $counter, string $payload) : string{ + $hash = openssl_digest(Binary::writeLLong($counter) . $payload . $this->key, self::CHECKSUM_ALGO, true); + if($hash === false){ + throw new \RuntimeException("openssl_digest() error: " . openssl_error_string()); + } + return substr($hash, 0, 8); + } +} diff --git a/src/pocketmine/network/mcpe/encryption/EncryptionUtils.php b/src/pocketmine/network/mcpe/encryption/EncryptionUtils.php new file mode 100644 index 000000000..0ad2ebfee --- /dev/null +++ b/src/pocketmine/network/mcpe/encryption/EncryptionUtils.php @@ -0,0 +1,68 @@ + base64_encode($derPublicKey), + "alg" => "ES384" + ], + [ + "salt" => base64_encode($salt) + ], + $serverPriv + ); + } +} diff --git a/src/pocketmine/network/mcpe/encryption/PrepareEncryptionTask.php b/src/pocketmine/network/mcpe/encryption/PrepareEncryptionTask.php new file mode 100644 index 000000000..1a4b6dd2e --- /dev/null +++ b/src/pocketmine/network/mcpe/encryption/PrepareEncryptionTask.php @@ -0,0 +1,96 @@ + ["curve_name" => "secp384r1"]]); + if($serverPrivateKey === false){ + throw new \RuntimeException("openssl_pkey_new() failed: " . openssl_error_string()); + } + self::$SERVER_PRIVATE_KEY = $serverPrivateKey; + } + + $this->serverPrivateKey = igbinary_serialize(openssl_pkey_get_details(self::$SERVER_PRIVATE_KEY)); + $this->clientPub = $clientPub; + $this->storeLocal($onCompletion); + } + + public function onRun() : void{ + /** @var mixed[] $serverPrivDetails */ + $serverPrivDetails = igbinary_unserialize($this->serverPrivateKey); + $serverPriv = openssl_pkey_new($serverPrivDetails); + if($serverPriv === false) throw new AssumptionFailedError("Failed to restore server signing key from details"); + $clientPub = JwtUtils::parseDerPublicKey($this->clientPub); + $sharedSecret = EncryptionUtils::generateSharedSecret($serverPriv, $clientPub); + + $salt = random_bytes(16); + $this->aesKey = EncryptionUtils::generateKey($sharedSecret, $salt); + $this->handshakeJwt = EncryptionUtils::generateServerHandshakeJwt($serverPriv, $salt); + + @openssl_free_key($serverPriv); + @openssl_free_key($clientPub); + } + + public function onCompletion(Server $server) : void{ + /** + * @var \Closure $callback + * @phpstan-var \Closure(string $encryptionKey, string $handshakeJwt) : void $callback + */ + $callback = $this->fetchLocal(); + if($this->aesKey === null || $this->handshakeJwt === null){ + throw new AssumptionFailedError("Something strange happened here ..."); + } + $callback($this->aesKey, $this->handshakeJwt); + } +} diff --git a/src/pocketmine/resources/pocketmine.yml b/src/pocketmine/resources/pocketmine.yml index f12769bdf..68a00bd73 100644 --- a/src/pocketmine/resources/pocketmine.yml +++ b/src/pocketmine/resources/pocketmine.yml @@ -92,6 +92,9 @@ network: #Maximum size in bytes of packets sent over the network (default 1492 bytes). Packets larger than this will be #fragmented or split into smaller parts. Clients can request MTU sizes up to but not more than this number. max-mtu-size: 1492 + #Enable encryption of Minecraft network traffic. This has an impact on performance, but prevents hackers from stealing sessions and pretending to be other players. + #DO NOT DISABLE THIS unless you understand the risks involved. + enable-encryption: true debug: #If > 1, it will show debug messages in the console