From 31f4b496a15f99f9c513fb75ee9f2a28b13624fe Mon Sep 17 00:00:00 2001 From: "Dylan T." Date: Sat, 20 Sep 2025 22:40:38 +0100 Subject: [PATCH 1/6] Implement new OpenID authentication system (#6798) Co-authored-by: Dries C <15795262+dries-c@users.noreply.github.com> --- src/Server.php | 8 + src/network/mcpe/JwtUtils.php | 69 +++-- src/network/mcpe/auth/AuthJwtHelper.php | 165 ++++++++++++ src/network/mcpe/auth/AuthKeyProvider.php | 164 ++++++++++++ src/network/mcpe/auth/AuthKeyring.php | 45 ++++ src/network/mcpe/auth/FetchAuthKeysTask.php | 209 +++++++++++++++ .../mcpe/auth/ProcessLegacyLoginTask.php | 121 +++++++++ src/network/mcpe/auth/ProcessLoginTask.php | 213 ---------------- .../mcpe/auth/ProcessOpenIdLoginTask.php | 98 +++++++ .../mcpe/handler/LoginPacketHandler.php | 241 +++++++++++------- tests/phpstan/configs/actual-problems.neon | 6 - 11 files changed, 1012 insertions(+), 327 deletions(-) create mode 100644 src/network/mcpe/auth/AuthJwtHelper.php create mode 100644 src/network/mcpe/auth/AuthKeyProvider.php create mode 100644 src/network/mcpe/auth/AuthKeyring.php create mode 100644 src/network/mcpe/auth/FetchAuthKeysTask.php create mode 100644 src/network/mcpe/auth/ProcessLegacyLoginTask.php delete mode 100644 src/network/mcpe/auth/ProcessLoginTask.php create mode 100644 src/network/mcpe/auth/ProcessOpenIdLoginTask.php diff --git a/src/Server.php b/src/Server.php index d6f0a8415..4f0fa4ce1 100644 --- a/src/Server.php +++ b/src/Server.php @@ -50,6 +50,7 @@ use pocketmine\lang\Language; use pocketmine\lang\LanguageNotFoundException; use pocketmine\lang\Translatable; use pocketmine\nbt\tag\CompoundTag; +use pocketmine\network\mcpe\auth\AuthKeyProvider; use pocketmine\network\mcpe\compression\CompressBatchPromise; use pocketmine\network\mcpe\compression\CompressBatchTask; use pocketmine\network\mcpe\compression\Compressor; @@ -270,6 +271,7 @@ class Server{ private int $maxPlayers; private bool $onlineMode = true; + private AuthKeyProvider $authKeyProvider; private Network $network; private bool $networkCompressionAsync = true; @@ -982,6 +984,8 @@ class Server{ $this->logger->warning($this->language->translate(KnownTranslationFactory::pocketmine_server_authProperty_disabled())); } + $this->authKeyProvider = new AuthKeyProvider(new \PrefixedLogger($this->logger, "Minecraft Auth Key Provider"), $this->asyncPool); + if($this->configGroup->getConfigBool(ServerProperties::HARDCORE, false) && $this->getDifficulty() < World::DIFFICULTY_HARD){ $this->configGroup->setConfigInt(ServerProperties::DIFFICULTY, World::DIFFICULTY_HARD); } @@ -1800,6 +1804,10 @@ class Server{ return $this->forceLanguage; } + public function getAuthKeyProvider() : AuthKeyProvider{ + return $this->authKeyProvider; + } + public function getNetwork() : Network{ return $this->network; } diff --git a/src/network/mcpe/JwtUtils.php b/src/network/mcpe/JwtUtils.php index 987ed6e61..dfdfada83 100644 --- a/src/network/mcpe/JwtUtils.php +++ b/src/network/mcpe/JwtUtils.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; use pocketmine\utils\AssumptionFailedError; +use pocketmine\utils\Binary; use pocketmine\utils\BinaryStream; use pocketmine\utils\Utils; use function base64_decode; @@ -32,6 +33,7 @@ use function bin2hex; use function chr; use function count; use function explode; +use function hex2bin; use function is_array; use function json_decode; use function json_encode; @@ -54,6 +56,7 @@ use function strlen; use function strtr; use function substr; use const JSON_THROW_ON_ERROR; +use const OPENSSL_ALGO_SHA256; use const OPENSSL_ALGO_SHA384; use const STR_PAD_LEFT; @@ -170,17 +173,17 @@ final class JwtUtils{ /** * @throws JwtException */ - public static function verify(string $jwt, \OpenSSLAsymmetricKey $signingKey) : bool{ + public static function verify(string $jwt, string $signingKeyDer, bool $ec) : bool{ [$header, $body, $signature] = self::split($jwt); $rawSignature = self::b64UrlDecode($signature); - $derSignature = self::rawSignatureToDer($rawSignature); + $derSignature = $ec ? self::rawSignatureToDer($rawSignature) : $rawSignature; $v = openssl_verify( $header . '.' . $body, $derSignature, - $signingKey, - self::SIGNATURE_ALGORITHM + self::derPublicKeyToPem($signingKeyDer), + $ec ? self::SIGNATURE_ALGORITHM : OPENSSL_ALGO_SHA256 ); switch($v){ case 0: return false; @@ -238,22 +241,56 @@ final class JwtUtils{ throw new AssumptionFailedError("OpenSSL resource contains invalid public key"); } + /** + * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information. + */ + private static function encodeDerLength(int $length) : string{ + if ($length <= 0x7F) { + return chr($length); + } + + $lengthBytes = ltrim(Binary::writeInt($length), "\x00"); + + return chr(0x80 | strlen($lengthBytes)) . $lengthBytes; + } + + private static function encodeDerBytes(int $tag, string $data) : string{ + return chr($tag) . self::encodeDerLength(strlen($data)) . $data; + } + 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))); + $signingKeyOpenSSL = openssl_pkey_get_public(self::derPublicKeyToPem($derKey)); if($signingKeyOpenSSL === false){ throw new JwtException("OpenSSL failed to parse key: " . openssl_error_string()); } - $details = openssl_pkey_get_details($signingKeyOpenSSL); - if($details === false){ - throw new JwtException("OpenSSL failed to get details from key: " . openssl_error_string()); - } - if(!isset($details['ec']['curve_name'])){ - throw new JwtException("Expected an EC key"); - } - $curve = $details['ec']['curve_name']; - if($curve !== self::BEDROCK_SIGNING_KEY_CURVE_NAME){ - throw new JwtException("Key must belong to curve " . self::BEDROCK_SIGNING_KEY_CURVE_NAME . ", got $curve"); - } return $signingKeyOpenSSL; } + + public static function derPublicKeyToPem(string $derKey) : string{ + return sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", base64_encode($derKey)); + } + + /** + * Create a public key represented in DER format from RSA modulus and exponent information + * + * @param string $nBase64 The RSA modulus encoded in Base64 + * @param string $eBase64 The RSA exponent encoded in Base64 + */ + public static function rsaPublicKeyModExpToDer(string $nBase64, string $eBase64) : string{ + $mod = self::b64UrlDecode($nBase64); + $exp = self::b64UrlDecode($eBase64); + + $modulus = self::encodeDerBytes(2, $mod); + $publicExponent = self::encodeDerBytes(2, $exp); + + $rsaPublicKey = self::encodeDerBytes(48, $modulus . $publicExponent); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = hex2bin('300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $rsaPublicKey = chr(0) . $rsaPublicKey; + $rsaPublicKey = self::encodeDerBytes(3, $rsaPublicKey); + + return self::encodeDerBytes(48, $rsaOID . $rsaPublicKey); + } } diff --git a/src/network/mcpe/auth/AuthJwtHelper.php b/src/network/mcpe/auth/AuthJwtHelper.php new file mode 100644 index 000000000..5050c396f --- /dev/null +++ b/src/network/mcpe/auth/AuthJwtHelper.php @@ -0,0 +1,165 @@ +nbf) && $claims->nbf > $time + self::CLOCK_DRIFT_MAX){ + throw new VerifyLoginException("JWT not yet valid", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooEarly()); + } + + if(isset($claims->exp) && $claims->exp < $time - self::CLOCK_DRIFT_MAX){ + throw new VerifyLoginException("JWT expired", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooLate()); + } + } + + /** + * @throws VerifyLoginException if errors are encountered + */ + public static function validateOpenIdAuthToken(string $jwt, string $signingKeyDer, string $issuer, string $audience) : XboxAuthJwtBody{ + try{ + if(!JwtUtils::verify($jwt, $signingKeyDer, ec: false)){ + throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); + } + }catch(JwtException $e){ + throw new VerifyLoginException($e->getMessage(), null, 0, $e); + } + + try{ + [, $claimsArray, ] = JwtUtils::parse($jwt); + }catch(JwtException $e){ + throw new VerifyLoginException("Failed to parse JWT: " . $e->getMessage(), null, 0, $e); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + + try{ + //nasty dynamic new for JsonMapper + $claims = $mapper->map($claimsArray, new XboxAuthJwtBody()); + }catch(\JsonMapper_Exception $e){ + throw new VerifyLoginException("Invalid chain link body: " . $e->getMessage(), null, 0, $e); + } + + if(!isset($claims->iss) || $claims->iss !== $issuer){ + throw new VerifyLoginException("Invalid JWT issuer"); + } + + if(!isset($claims->aud) || $claims->aud !== $audience){ + throw new VerifyLoginException("Invalid JWT audience"); + } + + self::checkExpiry($claims); + + return $claims; + } + + /** + * @throws VerifyLoginException if errors are encountered + */ + public static function validateLegacyAuthToken(string $jwt, ?string $expectedKeyDer) : LegacyAuthJwtBody{ + self::validateSelfSignedToken($jwt, $expectedKeyDer); + + //TODO: this parses the JWT twice and throws away a bunch of parts, optimize this + [, $claimsArray, ] = JwtUtils::parse($jwt); + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + try{ + /** @var LegacyAuthJwtBody $claims */ + $claims = $mapper->map($claimsArray, new LegacyAuthJwtBody()); + }catch(\JsonMapper_Exception $e){ + throw new VerifyLoginException("Invalid chain link body: " . $e->getMessage(), null, 0, $e); + } + + self::checkExpiry($claims); + + return $claims; + } + + public static function validateSelfSignedToken(string $jwt, ?string $expectedKeyDer) : void{ + try{ + [$headersArray, ] = JwtUtils::parse($jwt); + }catch(JwtException $e){ + throw new VerifyLoginException("Failed to parse JWT: " . $e->getMessage(), null, 0, $e); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnMissingData = true; + $mapper->bExceptionOnUndefinedProperty = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + + try{ + /** @var SelfSignedJwtHeader $headers */ + $headers = $mapper->map($headersArray, new SelfSignedJwtHeader()); + }catch(\JsonMapper_Exception $e){ + throw new VerifyLoginException("Invalid JWT header: " . $e->getMessage(), null, 0, $e); + } + + $headerDerKey = base64_decode($headers->x5u, true); + if($headerDerKey === false){ + throw new VerifyLoginException("Invalid JWT public key: base64 decoding error decoding x5u"); + } + if($expectedKeyDer !== null && $headerDerKey !== $expectedKeyDer){ + //Fast path: if the header key doesn't match what we expected, the signature isn't going to validate anyway + throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); + } + + try{ + if(!JwtUtils::verify($jwt, $headerDerKey, ec: true)){ + throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); + } + }catch(JwtException $e){ + throw new VerifyLoginException($e->getMessage(), null, 0, $e); + } + } +} diff --git a/src/network/mcpe/auth/AuthKeyProvider.php b/src/network/mcpe/auth/AuthKeyProvider.php new file mode 100644 index 000000000..67ffe4908 --- /dev/null +++ b/src/network/mcpe/auth/AuthKeyProvider.php @@ -0,0 +1,164 @@ + */ + private ?PromiseResolver $resolver = null; + + private int $lastFetch = 0; + + public function __construct( + private readonly \Logger $logger, + private readonly AsyncPool $asyncPool, + private readonly int $keyRefreshIntervalSeconds = self::ALLOWED_REFRESH_INTERVAL + ){} + + /** + * Fetches the key for the given key ID. + * The promise will be resolved with an array of [issuer, pemPublicKey]. + * + * @phpstan-return Promise + */ + public function getKey(string $keyId) : Promise{ + /** @phpstan-var PromiseResolver $resolver */ + $resolver = new PromiseResolver(); + + if( + $this->keyring === null || //we haven't fetched keys yet + ($this->keyring->getKey($keyId) === null && $this->lastFetch < time() - $this->keyRefreshIntervalSeconds) //we don't recognise this one & keys might be outdated + ){ + //only refresh keys when we see one we don't recognise + $this->fetchKeys()->onCompletion( + onSuccess: fn(AuthKeyring $newKeyring) => $this->resolveKey($resolver, $newKeyring, $keyId), + onFailure: $resolver->reject(...) + ); + }else{ + $this->resolveKey($resolver, $this->keyring, $keyId); + } + + return $resolver->getPromise(); + } + + /** + * @phpstan-param PromiseResolver $resolver + */ + private function resolveKey(PromiseResolver $resolver, AuthKeyring $keyring, string $keyId) : void{ + $key = $keyring->getKey($keyId); + if($key === null){ + $this->logger->debug("Key $keyId not recognised!"); + $resolver->reject(); + return; + } + + $this->logger->debug("Key $keyId found in keychain"); + $resolver->resolve([$keyring->getIssuer(), $key]); + } + + /** + * @phpstan-param array|null $keys + * @phpstan-param string[]|null $errors + */ + private function onKeysFetched(?array $keys, string $issuer, ?array $errors) : void{ + $resolver = $this->resolver; + if($resolver === null){ + throw new AssumptionFailedError("Not expecting this to be called without a resolver present"); + } + if($errors !== null){ + $this->logger->error("The following errors occurred while fetching new keys:\n\t- " . implode("\n\t-", $errors)); + //we might've still succeeded in fetching keys even if there were errors, so don't return + } + + if($keys === null){ + $this->logger->critical("Failed to fetch authentication keys from Mojang's API. Xbox players may not be able to authenticate!"); + $resolver->reject(); + }else{ + $pemKeys = []; + foreach($keys as $keyModel){ + if($keyModel->use !== "sig" || $keyModel->kty !== "RSA"){ + $this->logger->error("Key ID $keyModel->kid doesn't have the expected properties: expected use=sig, kty=RSA, got use=$keyModel->use, kty=$keyModel->kty"); + continue; + } + $derKey = JwtUtils::rsaPublicKeyModExpToDer($keyModel->n, $keyModel->e); + + //make sure the key is valid + try{ + JwtUtils::parseDerPublicKey($derKey); + }catch(JwtException $e){ + $this->logger->error("Failed to parse RSA public key for key ID $keyModel->kid: " . $e->getMessage()); + $this->logger->logException($e); + continue; + } + + //retain PEM keys instead of OpenSSLAsymmetricKey since these are easier and cheaper to copy between threads + $pemKeys[$keyModel->kid] = $derKey; + } + + if(count($keys) === 0){ + $this->logger->critical("No valid authentication keys returned by Mojang's API. Xbox players may not be able to authenticate!"); + $resolver->reject(); + }else{ + $this->logger->info("Successfully fetched " . count($keys) . " new authentication keys from issuer $issuer, key IDs: " . implode(", ", array_keys($pemKeys))); + $this->keyring = new AuthKeyring($issuer, $pemKeys); + $this->lastFetch = time(); + $resolver->resolve($this->keyring); + } + } + } + + /** + * @phpstan-return Promise + */ + private function fetchKeys() : Promise{ + if($this->resolver !== null){ + $this->logger->debug("Key refresh was requested, but it's already in progress"); + return $this->resolver->getPromise(); + } + + $this->logger->notice("Fetching new authentication keys"); + + /** @phpstan-var PromiseResolver $resolver */ + $resolver = new PromiseResolver(); + $this->resolver = $resolver; + //TODO: extract this so it can be polyfilled for unit testing + $this->asyncPool->submitTask(new FetchAuthKeysTask($this->onKeysFetched(...))); + return $this->resolver->getPromise(); + } +} diff --git a/src/network/mcpe/auth/AuthKeyring.php b/src/network/mcpe/auth/AuthKeyring.php new file mode 100644 index 000000000..cd5d29f6e --- /dev/null +++ b/src/network/mcpe/auth/AuthKeyring.php @@ -0,0 +1,45 @@ + $keys + */ + public function __construct( + private string $issuer, + private array $keys + ){} + + public function getIssuer() : string{ return $this->issuer; } + + /** + * Returns a (raw) DER public key associated with the given key ID + */ + public function getKey(string $keyId) : ?string{ + return $this->keys[$keyId] ?? null; + } +} diff --git a/src/network/mcpe/auth/FetchAuthKeysTask.php b/src/network/mcpe/auth/FetchAuthKeysTask.php new file mode 100644 index 000000000..b159d42af --- /dev/null +++ b/src/network/mcpe/auth/FetchAuthKeysTask.php @@ -0,0 +1,209 @@ +> */ + private ?NonThreadSafeValue $keys = null; + private string $issuer; + + /** @phpstan-var ?NonThreadSafeValue> */ + private ?NonThreadSafeValue $errors = null; + + /** + * @phpstan-param \Closure(?array $keys, string $issuer, ?string[] $errors) : void $onCompletion + */ + public function __construct( + \Closure $onCompletion + ){ + $this->storeLocal(self::KEYS_ON_COMPLETION, $onCompletion); + } + + public function onRun() : void{ + /** @var string[] $errors */ + $errors = []; + + try{ + $authServiceUri = $this->getAuthServiceURI(); + }catch(\RuntimeException $e){ + $errors[] = $e->getMessage(); + $authServiceUri = self::AUTHORIZATION_SERVICE_URI_FALLBACK; + } + + try { + $openIdConfig = $this->getOpenIdConfiguration($authServiceUri); + $jwksUri = $openIdConfig->jwks_uri; + + $this->issuer = $openIdConfig->issuer; + } catch (\RuntimeException $e) { + $errors[] = $e->getMessage(); + $jwksUri = $authServiceUri . self::AUTHORIZATION_SERVICE_KEYS_PATH; + + $this->issuer = $authServiceUri; + } + + try{ + $this->keys = new NonThreadSafeValue($this->getKeys($jwksUri)); + }catch(\RuntimeException $e){ + $errors[] = $e->getMessage(); + } + + $this->errors = $errors === [] ? null : new NonThreadSafeValue($errors); + } + + private function getAuthServiceURI() : string{ + $result = Internet::getURL(self::MINECRAFT_SERVICES_DISCOVERY_URL); + if($result === null || $result->getCode() !== 200){ + throw new \RuntimeException("Failed to fetch Minecraft services discovery document"); + } + + try{ + $json = json_decode($result->getBody(), false, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw new \RuntimeException($e->getMessage(), 0, $e); + } + if(!is_object($json)){ + throw new \RuntimeException("Unexpected root type of schema file " . gettype($json) . ", expected object"); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + try{ + /** @var MinecraftServicesDiscovery $discovery */ + $discovery = $mapper->map($json, new MinecraftServicesDiscovery()); + }catch(\JsonMapper_Exception $e){ + throw new \RuntimeException("Invalid schema file: " . $e->getMessage(), 0, $e); + } + + return $discovery->result->serviceEnvironments->auth->prod->serviceUri; + } + + private function getOpenIdConfiguration(string $authServiceUri) : AuthServiceOpenIdConfiguration{ + $result = Internet::getURL($authServiceUri . self::AUTHORIZATION_SERVICE_OPENID_CONFIGURATION_PATH); + if($result === null || $result->getCode() !== 200){ + throw new \RuntimeException("Failed to fetch OpenID configuration from authorization service"); + } + + try{ + $json = json_decode($result->getBody(), false, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw new \RuntimeException($e->getMessage(), 0, $e); + } + if(!is_object($json)){ + throw new \RuntimeException("Unexpected root type of schema file " . gettype($json) . ", expected object"); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + try{ + /** @var AuthServiceOpenIdConfiguration $configuration */ + $configuration = $mapper->map($json, new AuthServiceOpenIdConfiguration()); + }catch(\JsonMapper_Exception $e){ + throw new \RuntimeException("Invalid schema file: " . $e->getMessage(), 0, $e); + } + + return $configuration; + } + + /** + * @return array keys indexed by key ID + */ + private function getKeys(string $jwksUri) : array{ + $result = Internet::getURL($jwksUri); + if($result === null || $result->getCode() !== 200){ + return throw new \RuntimeException("Failed to fetch keys from authorization service"); + } + + try{ + $json = json_decode($result->getBody(), true, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw new \RuntimeException($e->getMessage(), 0, $e); + } + + if(!is_array($json) || !isset($json["keys"]) || !is_array($keysArray = $json["keys"])){ + throw new \RuntimeException("Unexpected root type of schema file " . gettype($json) . ", expected object"); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = true; + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + + $keys = []; + foreach($keysArray as $keyJson){ + if(!is_array($keyJson)){ + throw new \RuntimeException("Unexpected key type in schema file: " . gettype($keyJson) . ", expected object"); + } + + try{ + /** @var AuthServiceKey $key */ + $key = $mapper->map($keyJson, new AuthServiceKey()); + $keys[$key->kid] = $key; + }catch(\JsonMapper_Exception $e){ + throw new \RuntimeException("Invalid schema file: " . $e->getMessage(), 0, $e); + } + } + + return $keys; + } + + public function onCompletion() : void{ + /** + * @var \Closure $callback + * @phpstan-var \Closure(?AuthServiceKey[] $keys, string $issuer, ?string[] $errors) : void $callback + */ + $callback = $this->fetchLocal(self::KEYS_ON_COMPLETION); + $callback($this->keys?->deserialize(), $this->issuer, $this->errors?->deserialize()); + } +} diff --git a/src/network/mcpe/auth/ProcessLegacyLoginTask.php b/src/network/mcpe/auth/ProcessLegacyLoginTask.php new file mode 100644 index 000000000..2be55dab6 --- /dev/null +++ b/src/network/mcpe/auth/ProcessLegacyLoginTask.php @@ -0,0 +1,121 @@ +|string|null + */ + private NonThreadSafeValue|string|null $error = "Unknown"; + /** Whether the player has a certificate chain link signed by the given root public key. */ + private bool $authenticated = false; + private ?string $clientPublicKeyDer = null; + + /** + * @param string[] $chainJwts + * @phpstan-param \Closure(bool $isAuthenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPublicKey) : void $onCompletion + */ + public function __construct( + array $chainJwts, + private string $clientDataJwt, + private ?string $rootAuthKeyDer, + private bool $authRequired, + \Closure $onCompletion + ){ + $this->storeLocal(self::TLS_KEY_ON_COMPLETION, $onCompletion); + $this->chain = igbinary_serialize($chainJwts) ?? throw new AssumptionFailedError("This should never fail"); + } + + public function onRun() : void{ + try{ + $this->clientPublicKeyDer = $this->validateChain(); + AuthJwtHelper::validateSelfSignedToken($this->clientDataJwt, $this->clientPublicKeyDer); + $this->error = null; + }catch(VerifyLoginException $e){ + $disconnectMessage = $e->getDisconnectMessage(); + $this->error = $disconnectMessage instanceof Translatable ? new NonThreadSafeValue($disconnectMessage) : $disconnectMessage; + } + } + + private function validateChain() : string{ + /** @var string[] $chain */ + $chain = igbinary_unserialize($this->chain); + + $identityPublicKeyDer = null; + + foreach($chain as $jwt){ + $claims = AuthJwtHelper::validateLegacyAuthToken($jwt, $identityPublicKeyDer); + if($this->rootAuthKeyDer !== null && $identityPublicKeyDer === $this->rootAuthKeyDer){ + $this->authenticated = true; //we're signed into xbox live, according to this root key + } + if(!isset($claims->identityPublicKey)){ + throw new VerifyLoginException("Missing identityPublicKey in chain link", KnownTranslationFactory::pocketmine_disconnect_invalidSession_missingKey()); + } + $identityPublicKey = base64_decode($claims->identityPublicKey, true); + if($identityPublicKey === false){ + throw new VerifyLoginException("Invalid identityPublicKey: base64 error decoding"); + } + $identityPublicKeyDer = $identityPublicKey; + } + + if($identityPublicKeyDer === null){ + throw new VerifyLoginException("No authentication chain links provided"); + } + + return $identityPublicKeyDer; + } + + public function onCompletion() : void{ + /** + * @var \Closure $callback + * @phpstan-var \Closure(bool, bool, Translatable|string|null, ?string) : void $callback + */ + $callback = $this->fetchLocal(self::TLS_KEY_ON_COMPLETION); + $callback($this->authenticated, $this->authRequired, $this->error instanceof NonThreadSafeValue ? $this->error->deserialize() : $this->error, $this->clientPublicKeyDer); + } +} diff --git a/src/network/mcpe/auth/ProcessLoginTask.php b/src/network/mcpe/auth/ProcessLoginTask.php deleted file mode 100644 index 218edc7a5..000000000 --- a/src/network/mcpe/auth/ProcessLoginTask.php +++ /dev/null @@ -1,213 +0,0 @@ -|string|null - */ - private NonThreadSafeValue|string|null $error = "Unknown"; - /** - * Whether the player is logged into Xbox Live. This is true if any link in the keychain is signed with the Mojang - * root public key. - */ - private bool $authenticated = false; - private ?string $clientPublicKey = null; - - /** - * @param string[] $chainJwts - * @phpstan-param \Closure(bool $isAuthenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPublicKey) : void $onCompletion - */ - public function __construct( - array $chainJwts, - private string $clientDataJwt, - private bool $authRequired, - \Closure $onCompletion - ){ - $this->storeLocal(self::TLS_KEY_ON_COMPLETION, $onCompletion); - $this->chain = igbinary_serialize($chainJwts); - } - - public function onRun() : void{ - try{ - $this->clientPublicKey = $this->validateChain(); - $this->error = null; - }catch(VerifyLoginException $e){ - $disconnectMessage = $e->getDisconnectMessage(); - $this->error = $disconnectMessage instanceof Translatable ? new NonThreadSafeValue($disconnectMessage) : $disconnectMessage; - } - } - - private function validateChain() : string{ - /** @var string[] $chain */ - $chain = igbinary_unserialize($this->chain); - - $currentKey = null; - $first = true; - - foreach($chain as $jwt){ - $this->validateToken($jwt, $currentKey, $first); - if($first){ - $first = false; - } - } - - /** @var string $clientKey */ - $clientKey = $currentKey; - - $this->validateToken($this->clientDataJwt, $currentKey); - - return $clientKey; - } - - /** - * @throws VerifyLoginException if errors are encountered - */ - private function validateToken(string $jwt, ?string &$currentPublicKey, bool $first = false) : void{ - try{ - [$headersArray, $claimsArray, ] = JwtUtils::parse($jwt); - }catch(JwtException $e){ - throw new VerifyLoginException("Failed to parse JWT: " . $e->getMessage(), null, 0, $e); - } - - $mapper = new \JsonMapper(); - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; - $mapper->bEnforceMapType = false; - - try{ - /** @var SelfSignedJwtHeader $headers */ - $headers = $mapper->map($headersArray, new SelfSignedJwtHeader()); - }catch(\JsonMapper_Exception $e){ - throw new VerifyLoginException("Invalid JWT header: " . $e->getMessage(), null, 0, $e); - } - - $headerDerKey = base64_decode($headers->x5u, true); - if($headerDerKey === false){ - throw new VerifyLoginException("Invalid JWT public key: base64 decoding error decoding x5u"); - } - - if($currentPublicKey === null){ - if(!$first){ - throw new VerifyLoginException("Missing JWT public key", KnownTranslationFactory::pocketmine_disconnect_invalidSession_missingKey()); - } - }elseif($headerDerKey !== $currentPublicKey){ - //Fast path: if the header key doesn't match what we expected, the signature isn't going to validate anyway - throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); - } - - try{ - $signingKeyOpenSSL = JwtUtils::parseDerPublicKey($headerDerKey); - }catch(JwtException $e){ - throw new VerifyLoginException("Invalid JWT public key: " . $e->getMessage(), null, 0, $e); - } - try{ - if(!JwtUtils::verify($jwt, $signingKeyOpenSSL)){ - throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); - } - }catch(JwtException $e){ - throw new VerifyLoginException($e->getMessage(), null, 0, $e); - } - - if($headers->x5u === self::MOJANG_ROOT_PUBLIC_KEY){ - $this->authenticated = true; //we're signed into xbox live - } - - $mapper = new \JsonMapper(); - $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case - $mapper->bExceptionOnMissingData = true; - $mapper->bStrictObjectTypeChecking = true; - $mapper->bEnforceMapType = false; - $mapper->bRemoveUndefinedAttributes = true; - try{ - /** @var LegacyAuthJwtBody $claims */ - $claims = $mapper->map($claimsArray, new LegacyAuthJwtBody()); - }catch(\JsonMapper_Exception $e){ - throw new VerifyLoginException("Invalid chain link body: " . $e->getMessage(), null, 0, $e); - } - - $time = time(); - if(isset($claims->nbf) && $claims->nbf > $time + self::CLOCK_DRIFT_MAX){ - throw new VerifyLoginException("JWT not yet valid", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooEarly()); - } - - if(isset($claims->exp) && $claims->exp < $time - self::CLOCK_DRIFT_MAX){ - throw new VerifyLoginException("JWT expired", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooLate()); - } - - if(isset($claims->identityPublicKey)){ - $identityPublicKey = base64_decode($claims->identityPublicKey, true); - if($identityPublicKey === false){ - throw new VerifyLoginException("Invalid identityPublicKey: base64 error decoding"); - } - try{ - //verify key format and parameters - JwtUtils::parseDerPublicKey($identityPublicKey); - }catch(JwtException $e){ - throw new VerifyLoginException("Invalid identityPublicKey: " . $e->getMessage(), null, 0, $e); - } - $currentPublicKey = $identityPublicKey; //if there are further links, the next link should be signed with this - } - } - - public function onCompletion() : void{ - /** - * @var \Closure $callback - * @phpstan-var \Closure(bool, bool, Translatable|string|null, ?string) : void $callback - */ - $callback = $this->fetchLocal(self::TLS_KEY_ON_COMPLETION); - $callback($this->authenticated, $this->authRequired, $this->error instanceof NonThreadSafeValue ? $this->error->deserialize() : $this->error, $this->clientPublicKey); - } -} diff --git a/src/network/mcpe/auth/ProcessOpenIdLoginTask.php b/src/network/mcpe/auth/ProcessOpenIdLoginTask.php new file mode 100644 index 000000000..091317a65 --- /dev/null +++ b/src/network/mcpe/auth/ProcessOpenIdLoginTask.php @@ -0,0 +1,98 @@ +|string|null + */ + private NonThreadSafeValue|string|null $error = "Unknown"; + /** + * Whether the player is logged into Xbox Live. This is true if any link in the keychain is signed with the Mojang + * root public key. + */ + private bool $authenticated = false; + private ?string $clientPublicKeyDer = null; + + /** + * @phpstan-param \Closure(bool $isAuthenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPublicKey) : void $onCompletion + */ + public function __construct( + private string $jwt, + private string $issuer, + private string $mojangPublicKeyDer, + private string $clientDataJwt, + private bool $authRequired, + \Closure $onCompletion + ){ + $this->storeLocal(self::TLS_KEY_ON_COMPLETION, $onCompletion); + } + + public function onRun() : void{ + try{ + $this->clientPublicKeyDer = $this->validateChain(); + $this->error = null; + }catch(VerifyLoginException $e){ + $disconnectMessage = $e->getDisconnectMessage(); + $this->error = $disconnectMessage instanceof Translatable ? new NonThreadSafeValue($disconnectMessage) : $disconnectMessage; + } + } + + private function validateChain() : string{ + $claims = AuthJwtHelper::validateOpenIdAuthToken($this->jwt, $this->mojangPublicKeyDer, issuer: $this->issuer, audience: self::MOJANG_AUDIENCE); + //validateToken will throw if the JWT is not valid + $this->authenticated = true; + + $clientDerKey = base64_decode($claims->cpk, strict: true); + if($clientDerKey === false){ + throw new VerifyLoginException("Invalid client public key: base64 error decoding"); + } + //no further validation needed - OpenSSL will bail if the key is invalid + AuthJwtHelper::validateSelfSignedToken($this->clientDataJwt, $clientDerKey); + + return $clientDerKey; + } + + public function onCompletion() : void{ + /** + * @var \Closure $callback + * @phpstan-var \Closure(bool, bool, Translatable|string|null, ?string) : void $callback + */ + $callback = $this->fetchLocal(self::TLS_KEY_ON_COMPLETION); + $callback($this->authenticated, $this->authRequired, $this->error instanceof NonThreadSafeValue ? $this->error->deserialize() : $this->error, $this->clientPublicKeyDer); + } +} diff --git a/src/network/mcpe/handler/LoginPacketHandler.php b/src/network/mcpe/handler/LoginPacketHandler.php index c664c4b9f..aa7c1da7a 100644 --- a/src/network/mcpe/handler/LoginPacketHandler.php +++ b/src/network/mcpe/handler/LoginPacketHandler.php @@ -27,7 +27,8 @@ use pocketmine\entity\InvalidSkinException; use pocketmine\event\player\PlayerPreLoginEvent; use pocketmine\lang\KnownTranslationFactory; use pocketmine\lang\Translatable; -use pocketmine\network\mcpe\auth\ProcessLoginTask; +use pocketmine\network\mcpe\auth\ProcessLegacyLoginTask; +use pocketmine\network\mcpe\auth\ProcessOpenIdLoginTask; use pocketmine\network\mcpe\JwtException; use pocketmine\network\mcpe\JwtUtils; use pocketmine\network\mcpe\NetworkSession; @@ -38,16 +39,23 @@ use pocketmine\network\mcpe\protocol\types\login\clientdata\ClientData; use pocketmine\network\mcpe\protocol\types\login\clientdata\ClientDataToSkinDataHelper; use pocketmine\network\mcpe\protocol\types\login\legacy\LegacyAuthChain; use pocketmine\network\mcpe\protocol\types\login\legacy\LegacyAuthIdentityData; +use pocketmine\network\mcpe\protocol\types\login\openid\XboxAuthJwtBody; +use pocketmine\network\mcpe\protocol\types\login\openid\XboxAuthJwtHeader; use pocketmine\network\PacketHandlingException; use pocketmine\player\Player; use pocketmine\player\PlayerInfo; use pocketmine\player\XboxLivePlayerInfo; use pocketmine\Server; use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use function chr; +use function count; use function gettype; use function is_array; use function is_object; use function json_decode; +use function md5; +use function ord; use const JSON_THROW_ON_ERROR; /** @@ -65,15 +73,95 @@ class LoginPacketHandler extends PacketHandler{ private \Closure $authCallback ){} + private static function calculateUuidFromXuid(string $xuid) : UuidInterface{ + $hash = md5("pocket-auth-1-xuid:" . $xuid, binary: true); + $hash[6] = chr((ord($hash[6]) & 0x0f) | 0x30); // set version to 3 + $hash[8] = chr((ord($hash[8]) & 0x3f) | 0x80); // set variant to RFC 4122 + + return Uuid::fromBytes($hash); + } + public function handleLogin(LoginPacket $packet) : bool{ $authInfo = $this->parseAuthInfo($packet->authInfoJson); - $jwtChain = $this->parseJwtChain($authInfo->Certificate); - $extraData = $this->fetchAuthData($jwtChain); - if(!Player::isValidUserName($extraData->displayName)){ + if($authInfo->AuthenticationType === AuthenticationType::FULL->value){ + try{ + [$headerArray, $claimsArray,] = JwtUtils::parse($authInfo->Token); + }catch(JwtException $e){ + throw PacketHandlingException::wrap($e, "Error parsing authentication token"); + } + $header = $this->mapXboxTokenHeader($headerArray); + $claims = $this->mapXboxTokenBody($claimsArray); + + $legacyUuid = self::calculateUuidFromXuid($claims->xid); + $username = $claims->xname; + $xuid = $claims->xid; + + $authRequired = $this->processLoginCommon($packet, $username, $legacyUuid, $xuid); + if($authRequired === null){ + //plugin cancelled + return true; + } + $this->processOpenIdLogin($authInfo->Token, $header->kid, $packet->clientDataJwt, $authRequired); + + }elseif($authInfo->AuthenticationType === AuthenticationType::SELF_SIGNED->value){ + try{ + $chainData = json_decode($authInfo->Certificate, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw PacketHandlingException::wrap($e, "Error parsing self-signed certificate chain"); + } + if(!is_object($chainData)){ + throw new PacketHandlingException("Unexpected type for self-signed certificate chain: " . gettype($chainData) . ", expected object"); + } + try{ + $chain = $this->defaultJsonMapper()->map($chainData, new LegacyAuthChain()); + }catch(\JsonMapper_Exception $e){ + throw PacketHandlingException::wrap($e, "Error mapping self-signed certificate chain"); + } + if(count($chain->chain) > 1 || !isset($chain->chain[0])){ + throw new PacketHandlingException("Expected exactly one certificate in self-signed certificate chain, got " . count($chain->chain)); + } + + try{ + [, $claimsArray, ] = JwtUtils::parse($chain->chain[0]); + }catch(JwtException $e){ + throw PacketHandlingException::wrap($e, "Error parsing self-signed certificate"); + } + if(!isset($claimsArray["extraData"]) || !is_array($claimsArray["extraData"])){ + throw new PacketHandlingException("Expected \"extraData\" to be present in self-signed certificate"); + } + + try{ + $claims = $this->defaultJsonMapper()->map($claimsArray["extraData"], new LegacyAuthIdentityData()); + }catch(\JsonMapper_Exception $e){ + throw PacketHandlingException::wrap($e, "Error mapping self-signed certificate extraData"); + } + + if(!Uuid::isValid($claims->identity)){ + throw new PacketHandlingException("Invalid UUID string in self-signed certificate: " . $claims->identity); + } + $legacyUuid = Uuid::fromString($claims->identity); + $username = $claims->displayName; + $xuid = ""; + + $authRequired = $this->processLoginCommon($packet, $username, $legacyUuid, $xuid); + if($authRequired === null){ + //plugin cancelled + return true; + } + $this->processSelfSignedLogin($chain->chain, $packet->clientDataJwt, $authRequired); + }else{ + throw new PacketHandlingException("Unsupported authentication type: $authInfo->AuthenticationType"); + } + + return true; + } + + private function processLoginCommon(LoginPacket $packet, string $username, UuidInterface $legacyUuid, string $xuid) : ?bool{ + if(!Player::isValidUserName($username)){ $this->session->disconnectWithError(KnownTranslationFactory::disconnectionScreen_invalidName()); - return true; + return null; } $clientData = $this->parseClientData($packet->clientDataJwt); @@ -86,32 +174,25 @@ class LoginPacketHandler extends PacketHandler{ disconnectScreenMessage: KnownTranslationFactory::disconnectionScreen_invalidSkin() ); - return true; + return null; } - if(!Uuid::isValid($extraData->identity)){ - throw new PacketHandlingException("Invalid login UUID"); - } - $uuid = Uuid::fromString($extraData->identity); - $arrClientData = (array) $clientData; - $arrClientData["TitleID"] = $extraData->titleId; - - if($extraData->XUID !== ""){ + if($xuid !== ""){ $playerInfo = new XboxLivePlayerInfo( - $extraData->XUID, - $extraData->displayName, - $uuid, + $xuid, + $username, + $legacyUuid, $skin, $clientData->LanguageCode, - $arrClientData + (array) $clientData ); }else{ $playerInfo = new PlayerInfo( - $extraData->displayName, - $uuid, + $username, + $legacyUuid, $skin, $clientData->LanguageCode, - $arrClientData + (array) $clientData ); } ($this->playerInfoConsumer)($playerInfo); @@ -144,12 +225,10 @@ class LoginPacketHandler extends PacketHandler{ $ev->call(); if(!$ev->isAllowed()){ $this->session->disconnect($ev->getFinalDisconnectReason(), $ev->getFinalDisconnectScreenMessage()); - return true; + return null; } - $this->processLogin($authInfo->Token, AuthenticationType::from($authInfo->AuthenticationType), $jwtChain->chain, $packet->clientDataJwt, $ev->isAuthRequired()); - - return true; + return $ev->isAuthRequired(); } /** @@ -162,13 +241,10 @@ class LoginPacketHandler extends PacketHandler{ throw PacketHandlingException::wrap($e); } if(!is_object($authInfoJson)){ - throw new \RuntimeException("Unexpected type for auth info data: " . gettype($authInfoJson) . ", expected object"); + throw new PacketHandlingException("Unexpected type for auth info data: " . gettype($authInfoJson) . ", expected object"); } - $mapper = new \JsonMapper(); - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; + $mapper = $this->defaultJsonMapper(); try{ $clientData = $mapper->map($authInfoJson, new AuthenticationInfo()); }catch(\JsonMapper_Exception $e){ @@ -178,68 +254,31 @@ class LoginPacketHandler extends PacketHandler{ } /** + * @param array $headerArray * @throws PacketHandlingException */ - protected function parseJwtChain(string $chainDataJwt) : LegacyAuthChain{ + protected function mapXboxTokenHeader(array $headerArray) : XboxAuthJwtHeader{ + $mapper = $this->defaultJsonMapper(); try{ - $jwtChainJson = json_decode($chainDataJwt, associative: false, flags: JSON_THROW_ON_ERROR); - }catch(\JsonException $e){ - throw PacketHandlingException::wrap($e); - } - if(!is_object($jwtChainJson)){ - throw new \RuntimeException("Unexpected type for JWT chain data: " . gettype($jwtChainJson) . ", expected object"); - } - - $mapper = new \JsonMapper(); - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; - try{ - $clientData = $mapper->map($jwtChainJson, new LegacyAuthChain()); + $header = $mapper->map($headerArray, new XboxAuthJwtHeader()); }catch(\JsonMapper_Exception $e){ throw PacketHandlingException::wrap($e); } - return $clientData; + return $header; } /** + * @param array $bodyArray * @throws PacketHandlingException */ - protected function fetchAuthData(LegacyAuthChain $chain) : LegacyAuthIdentityData{ - /** @var LegacyAuthIdentityData|null $extraData */ - $extraData = null; - foreach($chain->chain as $jwt){ - //validate every chain element - try{ - [, $claims, ] = JwtUtils::parse($jwt); - }catch(JwtException $e){ - throw PacketHandlingException::wrap($e); - } - if(isset($claims["extraData"])){ - if($extraData !== null){ - throw new PacketHandlingException("Found 'extraData' more than once in chainData"); - } - - if(!is_array($claims["extraData"])){ - throw new PacketHandlingException("'extraData' key should be an array"); - } - $mapper = new \JsonMapper(); - $mapper->bEnforceMapType = false; //TODO: we don't really need this as an array, but right now we don't have enough models - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; - try{ - /** @var LegacyAuthIdentityData $extraData */ - $extraData = $mapper->map($claims["extraData"], new LegacyAuthIdentityData()); - }catch(\JsonMapper_Exception $e){ - throw PacketHandlingException::wrap($e); - } - } + protected function mapXboxTokenBody(array $bodyArray) : XboxAuthJwtBody{ + $mapper = $this->defaultJsonMapper(); + try{ + $header = $mapper->map($bodyArray, new XboxAuthJwtBody()); + }catch(\JsonMapper_Exception $e){ + throw PacketHandlingException::wrap($e); } - if($extraData === null){ - throw new PacketHandlingException("'extraData' not found in chain data"); - } - return $extraData; + return $header; } /** @@ -252,11 +291,7 @@ class LoginPacketHandler extends PacketHandler{ throw PacketHandlingException::wrap($e); } - $mapper = new \JsonMapper(); - $mapper->bEnforceMapType = false; //TODO: we don't really need this as an array, but right now we don't have enough models - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; + $mapper = $this->defaultJsonMapper(); try{ $clientData = $mapper->map($clientDataClaims, new ClientData()); }catch(\JsonMapper_Exception $e){ @@ -269,15 +304,37 @@ class LoginPacketHandler extends PacketHandler{ * TODO: This is separated for the purposes of allowing plugins (like Specter) to hack it and bypass authentication. * In the future this won't be necessary. * - * @param null|string[] $legacyCertificate - * * @throws \InvalidArgumentException */ - protected function processLogin(string $token, AuthenticationType $authType, ?array $legacyCertificate, string $clientData, bool $authRequired) : void{ - if($legacyCertificate === null){ - throw new PacketHandlingException("Legacy certificate cannot be null"); - } - $this->server->getAsyncPool()->submitTask(new ProcessLoginTask($legacyCertificate, $clientData, $authRequired, $this->authCallback)); + protected function processOpenIdLogin(string $token, string $keyId, string $clientData, bool $authRequired) : void{ $this->session->setHandler(null); //drop packets received during login verification + + $authKeyProvider = $this->server->getAuthKeyProvider(); + + $authKeyProvider->getKey($keyId)->onCompletion( + function(array $issuerAndKey) use ($token, $clientData, $authRequired) : void{ + [$issuer, $mojangPublicKeyPem] = $issuerAndKey; + $this->server->getAsyncPool()->submitTask(new ProcessOpenIdLoginTask($token, $issuer, $mojangPublicKeyPem, $clientData, $authRequired, $this->authCallback)); + }, + fn() => ($this->authCallback)(false, $authRequired, "Unrecognized authentication key ID: $keyId", null) + ); + } + + /** + * @param string[] $legacyCertificate + */ + protected function processSelfSignedLogin(array $legacyCertificate, string $clientDataJwt, bool $authRequired) : void{ + $this->session->setHandler(null); //drop packets received during login verification + + $this->server->getAsyncPool()->submitTask(new ProcessLegacyLoginTask($legacyCertificate, $clientDataJwt, rootAuthKeyDer: null, authRequired: $authRequired, onCompletion: $this->authCallback)); + } + + private function defaultJsonMapper() : \JsonMapper{ + $mapper = new \JsonMapper(); + $mapper->bExceptionOnMissingData = true; + $mapper->bExceptionOnUndefinedProperty = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + return $mapper; } } diff --git a/tests/phpstan/configs/actual-problems.neon b/tests/phpstan/configs/actual-problems.neon index 76cebf283..fa513de78 100644 --- a/tests/phpstan/configs/actual-problems.neon +++ b/tests/phpstan/configs/actual-problems.neon @@ -870,12 +870,6 @@ parameters: count: 1 path: ../../../src/network/mcpe/NetworkSession.php - - - message: '#^Property pocketmine\\network\\mcpe\\auth\\ProcessLoginTask\:\:\$chain \(string\) does not accept string\|null\.$#' - identifier: assign.propertyType - count: 1 - path: ../../../src/network/mcpe/auth/ProcessLoginTask.php - - message: '#^Parameter \#1 \$result of method pocketmine\\network\\mcpe\\compression\\CompressBatchPromise\:\:resolve\(\) expects string, mixed given\.$#' identifier: argument.type From 45698e4bb9012766c1fb0a2bad64d2621707f9b5 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Tue, 23 Sep 2025 13:07:47 +0100 Subject: [PATCH 2/6] SendUsageTask: fixed php_uname() call with invalid mode this now throws in PHP 8.4. Previously it returned the same as php_uname(a). Presumably the intent was to capture the CPU arch and not a repeat of the machine field. closes #6811 --- src/stats/SendUsageTask.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stats/SendUsageTask.php b/src/stats/SendUsageTask.php index d218774b5..c49315a72 100644 --- a/src/stats/SendUsageTask.php +++ b/src/stats/SendUsageTask.php @@ -88,7 +88,7 @@ class SendUsageTask extends AsyncTask{ "phpVersion" => PHP_VERSION, "machine" => php_uname("a"), "release" => php_uname("r"), - "platform" => php_uname("i") + "platform" => php_uname("m") ]; $data["players"] = [ From 65cdf68f59d8cf622ffc2ea3860da2dc5313cc24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:20:37 +0100 Subject: [PATCH 3/6] Bump build/php from `627f8d6` to `8fe1873` (#6812) Bumps [build/php](https://github.com/pmmp/php-build-scripts) from `627f8d6` to `8fe1873`. - [Release notes](https://github.com/pmmp/php-build-scripts/releases) - [Commits](https://github.com/pmmp/php-build-scripts/compare/627f8d670cc12c6fb7ad988f0c85f65cd39d6f7e...8fe187335f666b8fb251927940f66ef372bfd3a6) --- updated-dependencies: - dependency-name: build/php dependency-version: 8fe187335f666b8fb251927940f66ef372bfd3a6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build/php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/php b/build/php index 627f8d670..8fe187335 160000 --- a/build/php +++ b/build/php @@ -1 +1 @@ -Subproject commit 627f8d670cc12c6fb7ad988f0c85f65cd39d6f7e +Subproject commit 8fe187335f666b8fb251927940f66ef372bfd3a6 From 3336cda34ad7f26af1d4511043d6b3badfbc8053 Mon Sep 17 00:00:00 2001 From: "Dylan T." Date: Thu, 25 Sep 2025 17:32:38 +0100 Subject: [PATCH 4/6] First pass ext-encoding support (high-level network I/O and read-only data) (#6799) This implements ext-encoding only in high-level network I/O (only BedrockProtocol and stuff implemented in PM) and read-only data. This should net a significant performance advantage while being low-risk in the case of critical issues with the extension. Any problems affecting protocol won't do permanent damage while being fairly easy to debug. Next passes will integrate ext-encoding versions of RakLib, RakLibIpc and NBT, as well as generally using ext-encoding for writeable data. --- composer.json | 3 +- composer.lock | 16 ++--- src/PocketMine.php | 7 +++ src/block/Block.php | 6 +- src/crafting/CraftingManager.php | 13 ++-- .../block/upgrade/BlockIdMetaUpgrader.php | 21 ++++--- src/network/mcpe/ChunkRequestTask.php | 6 +- src/network/mcpe/JwtUtils.php | 19 +++--- src/network/mcpe/NetworkSession.php | 32 +++++----- .../mcpe/StandardPacketBroadcaster.php | 11 ++-- src/network/mcpe/cache/CraftingDataCache.php | 6 +- src/network/mcpe/convert/TypeConverter.php | 9 +-- .../mcpe/encryption/EncryptionContext.php | 4 +- .../mcpe/serializer/ChunkSerializer.php | 62 +++++++++---------- src/network/query/QueryHandler.php | 48 +++++++------- src/network/query/QueryInfo.php | 4 +- src/world/format/io/FastChunkSerializer.php | 55 ++++++++-------- tools/generate-bedrock-data-from-packets.php | 11 ++-- 18 files changed, 177 insertions(+), 156 deletions(-) diff --git a/composer.json b/composer.json index 0f2ffe95f..9451fd6eb 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "ext-ctype": "*", "ext-curl": "*", "ext-date": "*", + "ext-encoding": "~1.0.0", "ext-gmp": "*", "ext-hash": "*", "ext-igbinary": "^3.0.1", @@ -36,7 +37,7 @@ "pocketmine/bedrock-block-upgrade-schema": "~5.1.0+bedrock-1.21.60", "pocketmine/bedrock-data": "~6.0.0+bedrock-1.21.100", "pocketmine/bedrock-item-upgrade-schema": "~1.15.0+bedrock-1.21.100", - "pocketmine/bedrock-protocol": "~41.0.0+bedrock-1.21.100", + "pocketmine/bedrock-protocol": "~50.0.0+bedrock-1.21.100", "pocketmine/binaryutils": "^0.2.1", "pocketmine/callback-validator": "^1.0.2", "pocketmine/color": "^0.3.0", diff --git a/composer.lock b/composer.lock index ece3c5070..ce923690e 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": "1e7545f6cc226b31d54238602143ba78", + "content-hash": "0d71d3fba23ba8c4734cac59b9e10129", "packages": [ { "name": "adhocore/json-comment", @@ -256,19 +256,20 @@ }, { "name": "pocketmine/bedrock-protocol", - "version": "41.0.0+bedrock-1.21.100", + "version": "50.0.0+bedrock-1.21.100", "source": { "type": "git", "url": "https://github.com/pmmp/BedrockProtocol.git", - "reference": "920ac291fe1b0143b2ebc90b3374ddab0b8531bf" + "reference": "2d7aa27a5537ae593fb1c39158648ea462fef72a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/920ac291fe1b0143b2ebc90b3374ddab0b8531bf", - "reference": "920ac291fe1b0143b2ebc90b3374ddab0b8531bf", + "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/2d7aa27a5537ae593fb1c39158648ea462fef72a", + "reference": "2d7aa27a5537ae593fb1c39158648ea462fef72a", "shasum": "" }, "require": { + "ext-encoding": "~1.0.0", "ext-json": "*", "php": "^8.1", "pocketmine/binaryutils": "^0.2.0", @@ -296,9 +297,9 @@ "description": "An implementation of the Minecraft: Bedrock Edition protocol in PHP", "support": { "issues": "https://github.com/pmmp/BedrockProtocol/issues", - "source": "https://github.com/pmmp/BedrockProtocol/tree/41.0.0+bedrock-1.21.100" + "source": "https://github.com/pmmp/BedrockProtocol/tree/50.0.0+bedrock-1.21.100" }, - "time": "2025-09-09T20:52:18+00:00" + "time": "2025-09-20T23:09:19+00:00" }, { "name": "pocketmine/binaryutils", @@ -2797,6 +2798,7 @@ "ext-ctype": "*", "ext-curl": "*", "ext-date": "*", + "ext-encoding": "~1.0.0", "ext-gmp": "*", "ext-hash": "*", "ext-igbinary": "^3.0.1", diff --git a/src/PocketMine.php b/src/PocketMine.php index a71c9768d..c34b8e221 100644 --- a/src/PocketMine.php +++ b/src/PocketMine.php @@ -98,6 +98,7 @@ namespace pocketmine { "crypto" => "php-crypto", "ctype" => "ctype", "date" => "Date", + "encoding" => "pmmp/ext-encoding", "gmp" => "GMP", "hash" => "Hash", "igbinary" => "igbinary", @@ -155,6 +156,12 @@ namespace pocketmine { } } + if(($encoding_version = phpversion("encoding")) !== false){ + if(version_compare($encoding_version, "1.0.0") < 0 || version_compare($encoding_version, "2.0.0") >= 0){ + $messages[] = "pmmp/ext-encoding ^1.0.0 is required, while you have $encoding_version."; + } + } + if(extension_loaded("pocketmine")){ $messages[] = "The native PocketMine extension is no longer supported."; } diff --git a/src/block/Block.php b/src/block/Block.php index 36e08fc0b..fd644eae4 100644 --- a/src/block/Block.php +++ b/src/block/Block.php @@ -26,6 +26,8 @@ declare(strict_types=1); */ namespace pocketmine\block; +use pmmp\encoding\BE; +use pmmp\encoding\LE; use pocketmine\block\tile\Spawnable; use pocketmine\block\tile\Tile; use pocketmine\block\utils\SupportType; @@ -49,7 +51,6 @@ use pocketmine\math\Vector3; use pocketmine\nbt\tag\CompoundTag; use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\Binary; use pocketmine\world\BlockTransaction; use pocketmine\world\format\Chunk; use pocketmine\world\Position; @@ -98,9 +99,10 @@ class Block{ * of operations required to compute the state ID (micro optimization). */ private static function computeStateIdXorMask(int $typeId) : int{ + //TODO: the mixed byte order here is probably a mistake, but it doesn't break anything for now return $typeId << self::INTERNAL_STATE_DATA_BITS | - (Binary::readLong(hash('xxh3', Binary::writeLLong($typeId), binary: true)) & self::INTERNAL_STATE_DATA_MASK); + (BE::unpackSignedLong(hash('xxh3', LE::packSignedLong($typeId), binary: true)) & self::INTERNAL_STATE_DATA_MASK); } /** diff --git a/src/crafting/CraftingManager.php b/src/crafting/CraftingManager.php index 93d6e1838..673095c6e 100644 --- a/src/crafting/CraftingManager.php +++ b/src/crafting/CraftingManager.php @@ -23,10 +23,11 @@ declare(strict_types=1); namespace pocketmine\crafting; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\VarInt; use pocketmine\item\Item; use pocketmine\nbt\LittleEndianNbtSerializer; use pocketmine\nbt\TreeRoot; -use pocketmine\utils\BinaryStream; use pocketmine\utils\DestructorCallbackTrait; use pocketmine\utils\ObjectSet; use function array_shift; @@ -114,11 +115,13 @@ class CraftingManager{ } private static function hashOutput(Item $output) : string{ - $write = new BinaryStream(); - $write->putVarInt($output->getStateId()); - $write->put((new LittleEndianNbtSerializer())->write(new TreeRoot($output->getNamedTag()))); + $write = new ByteBufferWriter(); + VarInt::writeSignedInt($write, $output->getStateId()); + //TODO: the NBT serializer allocates its own ByteBufferWriter, we should change the API in the future to + //allow passing our own to avoid this extra allocation + $write->writeByteArray((new LittleEndianNbtSerializer())->write(new TreeRoot($output->getNamedTag()))); - return $write->getBuffer(); + return $write->getData(); } /** diff --git a/src/data/bedrock/block/upgrade/BlockIdMetaUpgrader.php b/src/data/bedrock/block/upgrade/BlockIdMetaUpgrader.php index 1c339bd46..379784afb 100644 --- a/src/data/bedrock/block/upgrade/BlockIdMetaUpgrader.php +++ b/src/data/bedrock/block/upgrade/BlockIdMetaUpgrader.php @@ -23,11 +23,12 @@ declare(strict_types=1); namespace pocketmine\data\bedrock\block\upgrade; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\DataDecodeException; +use pmmp\encoding\VarInt; use pocketmine\data\bedrock\block\BlockStateData; use pocketmine\data\bedrock\block\BlockStateDeserializeException; use pocketmine\nbt\LittleEndianNbtSerializer; -use pocketmine\utils\BinaryDataException; -use pocketmine\utils\BinaryStream; /** * Handles translating legacy 1.12 block ID/meta into modern blockstates. @@ -84,25 +85,25 @@ final class BlockIdMetaUpgrader{ public static function loadFromString(string $data, LegacyBlockIdToStringIdMap $idMap, BlockStateUpgrader $blockStateUpgrader) : self{ $mappingTable = []; - $legacyStateMapReader = new BinaryStream($data); + $legacyStateMapReader = new ByteBufferReader($data); $nbtReader = new LittleEndianNbtSerializer(); - $idCount = $legacyStateMapReader->getUnsignedVarInt(); + $idCount = VarInt::readUnsignedInt($legacyStateMapReader); for($idIndex = 0; $idIndex < $idCount; $idIndex++){ - $id = $legacyStateMapReader->get($legacyStateMapReader->getUnsignedVarInt()); + $id = $legacyStateMapReader->readByteArray(VarInt::readUnsignedInt($legacyStateMapReader)); - $metaCount = $legacyStateMapReader->getUnsignedVarInt(); + $metaCount = VarInt::readUnsignedInt($legacyStateMapReader); for($metaIndex = 0; $metaIndex < $metaCount; $metaIndex++){ - $meta = $legacyStateMapReader->getUnsignedVarInt(); + $meta = VarInt::readUnsignedInt($legacyStateMapReader); $offset = $legacyStateMapReader->getOffset(); - $state = $nbtReader->read($legacyStateMapReader->getBuffer(), $offset)->mustGetCompoundTag(); + $state = $nbtReader->read($legacyStateMapReader->getData(), $offset)->mustGetCompoundTag(); $legacyStateMapReader->setOffset($offset); $mappingTable[$id][$meta] = $blockStateUpgrader->upgrade(BlockStateData::fromNbt($state)); } } - if(!$legacyStateMapReader->feof()){ - throw new BinaryDataException("Unexpected trailing data in legacy state map data"); + if($legacyStateMapReader->getUnreadLength() > 0){ + throw new DataDecodeException("Unexpected trailing data in legacy state map data"); } return new self($mappingTable, $idMap); diff --git a/src/network/mcpe/ChunkRequestTask.php b/src/network/mcpe/ChunkRequestTask.php index 13b5db5b7..357904dcb 100644 --- a/src/network/mcpe/ChunkRequestTask.php +++ b/src/network/mcpe/ChunkRequestTask.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\ByteBufferWriter; use pocketmine\network\mcpe\compression\CompressBatchPromise; use pocketmine\network\mcpe\compression\Compressor; use pocketmine\network\mcpe\convert\TypeConverter; @@ -33,7 +34,6 @@ use pocketmine\network\mcpe\protocol\types\DimensionIds; use pocketmine\network\mcpe\serializer\ChunkSerializer; use pocketmine\scheduler\AsyncTask; use pocketmine\thread\NonThreadSafeValue; -use pocketmine\utils\BinaryStream; use pocketmine\world\format\Chunk; use pocketmine\world\format\io\FastChunkSerializer; use function chr; @@ -73,11 +73,11 @@ class ChunkRequestTask extends AsyncTask{ $converter = TypeConverter::getInstance(); $payload = ChunkSerializer::serializeFullChunk($chunk, $dimensionId, $converter->getBlockTranslator(), $this->tiles); - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); PacketBatch::encodePackets($stream, [LevelChunkPacket::create(new ChunkPosition($this->chunkX, $this->chunkZ), $dimensionId, $subCount, false, null, $payload)]); $compressor = $this->compressor->deserialize(); - $this->setResult(chr($compressor->getNetworkId()) . $compressor->compress($stream->getBuffer())); + $this->setResult(chr($compressor->getNetworkId()) . $compressor->compress($stream->getData())); } public function onCompletion() : void{ diff --git a/src/network/mcpe/JwtUtils.php b/src/network/mcpe/JwtUtils.php index dfdfada83..8c25ee042 100644 --- a/src/network/mcpe/JwtUtils.php +++ b/src/network/mcpe/JwtUtils.php @@ -23,9 +23,10 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\BE; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferReader; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\Binary; -use pocketmine\utils\BinaryStream; use pocketmine\utils\Utils; use function base64_decode; use function base64_encode; @@ -133,17 +134,17 @@ final class JwtUtils{ return self::ASN1_SEQUENCE_TAG . chr(strlen($sequence)) . $sequence; } - private static function signaturePartFromAsn1(BinaryStream $stream) : string{ - $prefix = $stream->get(1); + private static function signaturePartFromAsn1(ByteBufferReader $stream) : string{ + $prefix = $stream->readByteArray(1); if($prefix !== self::ASN1_INTEGER_TAG){ throw new \InvalidArgumentException("Expected an ASN.1 INTEGER tag, got " . bin2hex($prefix)); } //we can assume the length is 1 byte here - if it were larger than 127, more complex logic would be needed - $length = $stream->getByte(); + $length = Byte::readUnsigned($stream); if($length > self::SIGNATURE_PART_LENGTH + 1){ //each part may have an extra leading 0 byte to prevent it being interpreted as a negative number throw new \InvalidArgumentException("Expected at most 49 bytes for signature R or S, got $length"); } - $part = $stream->get($length); + $part = $stream->readByteArray($length); return str_pad(ltrim($part, "\x00"), self::SIGNATURE_PART_LENGTH, "\x00", STR_PAD_LEFT); } @@ -159,11 +160,11 @@ final class JwtUtils{ throw new \InvalidArgumentException("Invalid DER signature, expected $length sequence bytes, got " . strlen($parts)); } - $stream = new BinaryStream($parts); + $stream = new ByteBufferReader($parts); $rRaw = self::signaturePartFromAsn1($stream); $sRaw = self::signaturePartFromAsn1($stream); - if(!$stream->feof()){ + if($stream->getUnreadLength() > 0){ throw new \InvalidArgumentException("Invalid DER signature, unexpected trailing sequence data"); } @@ -250,7 +251,7 @@ final class JwtUtils{ return chr($length); } - $lengthBytes = ltrim(Binary::writeInt($length), "\x00"); + $lengthBytes = ltrim(BE::packUnsignedInt($length), "\x00"); return chr(0x80 | strlen($lengthBytes)) . $lengthBytes; } diff --git a/src/network/mcpe/NetworkSession.php b/src/network/mcpe/NetworkSession.php index 234ad4765..75281e426 100644 --- a/src/network/mcpe/NetworkSession.php +++ b/src/network/mcpe/NetworkSession.php @@ -23,6 +23,9 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\DataDecodeException; use pocketmine\entity\effect\EffectInstance; use pocketmine\event\player\PlayerDuplicateLoginEvent; use pocketmine\event\player\PlayerResourcePackOfferEvent; @@ -70,7 +73,6 @@ use pocketmine\network\mcpe\protocol\PlayerStartItemCooldownPacket; use pocketmine\network\mcpe\protocol\PlayStatusPacket; use pocketmine\network\mcpe\protocol\ProtocolInfo; use pocketmine\network\mcpe\protocol\serializer\PacketBatch; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\ServerboundPacket; use pocketmine\network\mcpe\protocol\ServerToClientHandshakePacket; use pocketmine\network\mcpe\protocol\SetDifficultyPacket; @@ -109,8 +111,6 @@ use pocketmine\promise\PromiseResolver; use pocketmine\Server; use pocketmine\timings\Timings; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\BinaryDataException; -use pocketmine\utils\BinaryStream; use pocketmine\utils\ObjectSet; use pocketmine\utils\TextFormat; use pocketmine\world\format\io\GlobalItemDataHandlers; @@ -401,7 +401,7 @@ class NetworkSession{ } try{ - $stream = new BinaryStream($decompressed); + $stream = new ByteBufferReader($decompressed); foreach(PacketBatch::decodeRaw($stream) as $buffer){ $this->gamePacketLimiter->decrement(); $packet = $this->packetPool->getPacket($buffer); @@ -421,7 +421,7 @@ class NetworkSession{ break; } } - }catch(PacketDecodeException|BinaryDataException $e){ + }catch(PacketDecodeException|DataDecodeException $e){ $this->logger->logException($e); throw PacketHandlingException::wrap($e, "Packet batch decode error"); } @@ -453,14 +453,14 @@ class NetworkSession{ $decodeTimings = Timings::getDecodeDataPacketTimings($packet); $decodeTimings->startTiming(); try{ - $stream = PacketSerializer::decoder($buffer, 0); + $stream = new ByteBufferReader($buffer); try{ $packet->decode($stream); }catch(PacketDecodeException $e){ throw PacketHandlingException::wrap($e); } - if(!$stream->feof()){ - $remains = substr($stream->getBuffer(), $stream->getOffset()); + if($stream->getUnreadLength() > 0){ + $remains = substr($stream->getData(), $stream->getOffset()); $this->logger->debug("Still " . strlen($remains) . " bytes unread in " . $packet->getName() . ": " . bin2hex($remains)); } }finally{ @@ -478,7 +478,7 @@ class NetworkSession{ $handlerTimings->startTiming(); try{ if($this->handler === null || !$packet->handle($this->handler)){ - $this->logger->debug("Unhandled " . $packet->getName() . ": " . base64_encode($stream->getBuffer())); + $this->logger->debug("Unhandled " . $packet->getName() . ": " . base64_encode($stream->getData())); } }finally{ $handlerTimings->stopTiming(); @@ -530,8 +530,10 @@ class NetworkSession{ if($ackReceiptResolver !== null){ $this->sendBufferAckPromises[] = $ackReceiptResolver; } + $writer = new ByteBufferWriter(); foreach($packets as $evPacket){ - $this->addToSendBuffer(self::encodePacketTimed(PacketSerializer::encoder(), $evPacket)); + $writer->clear(); //memory reuse let's gooooo + $this->addToSendBuffer(self::encodePacketTimed($writer, $evPacket)); } if($immediate){ $this->flushGamePacketQueue(); @@ -564,12 +566,12 @@ class NetworkSession{ /** * @internal */ - public static function encodePacketTimed(PacketSerializer $serializer, ClientboundPacket $packet) : string{ + public static function encodePacketTimed(ByteBufferWriter $serializer, ClientboundPacket $packet) : string{ $timings = Timings::getEncodeDataPacketTimings($packet); $timings->startTiming(); try{ $packet->encode($serializer); - return $serializer->getBuffer(); + return $serializer->getData(); }finally{ $timings->stopTiming(); } @@ -591,13 +593,13 @@ class NetworkSession{ $syncMode = false; } - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); PacketBatch::encodeRaw($stream, $this->sendBuffer); if($this->enableCompression){ - $batch = $this->server->prepareBatch($stream->getBuffer(), $this->compressor, $syncMode, Timings::$playerNetworkSendCompressSessionBuffer); + $batch = $this->server->prepareBatch($stream->getData(), $this->compressor, $syncMode, Timings::$playerNetworkSendCompressSessionBuffer); }else{ - $batch = $stream->getBuffer(); + $batch = $stream->getData(); } $this->sendBuffer = []; $ackPromises = $this->sendBufferAckPromises; diff --git a/src/network/mcpe/StandardPacketBroadcaster.php b/src/network/mcpe/StandardPacketBroadcaster.php index 7a91b397b..5d9bc533e 100644 --- a/src/network/mcpe/StandardPacketBroadcaster.php +++ b/src/network/mcpe/StandardPacketBroadcaster.php @@ -23,12 +23,11 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\ByteBufferWriter; use pocketmine\event\server\DataPacketSendEvent; use pocketmine\network\mcpe\protocol\serializer\PacketBatch; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\Server; use pocketmine\timings\Timings; -use pocketmine\utils\BinaryStream; use function count; use function log; use function spl_object_id; @@ -64,8 +63,10 @@ final class StandardPacketBroadcaster implements PacketBroadcaster{ $totalLength = 0; $packetBuffers = []; + $writer = new ByteBufferWriter(); foreach($packets as $packet){ - $buffer = NetworkSession::encodePacketTimed(PacketSerializer::encoder(), $packet); + $writer->clear(); //memory reuse let's gooooo + $buffer = NetworkSession::encodePacketTimed($writer, $packet); //varint length prefix + packet buffer $totalLength += (((int) log(strlen($buffer), 128)) + 1) + strlen($buffer); $packetBuffers[] = $buffer; @@ -77,9 +78,9 @@ final class StandardPacketBroadcaster implements PacketBroadcaster{ $threshold = $compressor->getCompressionThreshold(); if(count($compressorTargets) > 1 && $threshold !== null && $totalLength >= $threshold){ //do not prepare shared batch unless we're sure it will be compressed - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); PacketBatch::encodeRaw($stream, $packetBuffers); - $batchBuffer = $stream->getBuffer(); + $batchBuffer = $stream->getData(); $batch = $this->server->prepareBatch($batchBuffer, $compressor, timings: Timings::$playerNetworkSendCompressBroadcast); foreach($compressorTargets as $target){ diff --git a/src/network/mcpe/cache/CraftingDataCache.php b/src/network/mcpe/cache/CraftingDataCache.php index da0f37c44..d873a53f0 100644 --- a/src/network/mcpe/cache/CraftingDataCache.php +++ b/src/network/mcpe/cache/CraftingDataCache.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\cache; +use pmmp\encoding\BE; use pocketmine\crafting\CraftingManager; use pocketmine\crafting\FurnaceType; use pocketmine\crafting\ShapedRecipe; @@ -41,7 +42,6 @@ use pocketmine\network\mcpe\protocol\types\recipe\ShapedRecipe as ProtocolShaped use pocketmine\network\mcpe\protocol\types\recipe\ShapelessRecipe as ProtocolShapelessRecipe; use pocketmine\timings\Timings; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\Binary; use pocketmine\utils\SingletonTrait; use Ramsey\Uuid\Uuid; use function array_map; @@ -99,7 +99,7 @@ final class CraftingDataCache{ }; $recipesWithTypeIds[] = new ProtocolShapelessRecipe( CraftingDataPacket::ENTRY_SHAPELESS, - Binary::writeInt($recipeNetId), + BE::packUnsignedInt($recipeNetId), //TODO: this should probably be changed to something human-readable array_map($converter->coreRecipeIngredientToNet(...), $recipe->getIngredientList()), array_map($converter->coreItemStackToNet(...), $recipe->getResults()), $nullUUID, @@ -118,7 +118,7 @@ final class CraftingDataCache{ } $recipesWithTypeIds[] = $r = new ProtocolShapedRecipe( CraftingDataPacket::ENTRY_SHAPED, - Binary::writeInt($recipeNetId), + BE::packUnsignedInt($recipeNetId), //TODO: this should probably be changed to something human-readable $inputs, array_map($converter->coreItemStackToNet(...), $recipe->getResults()), $nullUUID, diff --git a/src/network/mcpe/convert/TypeConverter.php b/src/network/mcpe/convert/TypeConverter.php index 2a3a4e8f3..9f946ac33 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\convert; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; use pocketmine\block\tile\Container; use pocketmine\block\VanillaBlocks; use pocketmine\crafting\ExactRecipeIngredient; @@ -44,7 +46,6 @@ use pocketmine\nbt\tag\Tag; use pocketmine\nbt\TreeRoot; use pocketmine\nbt\UnexpectedTagTypeException; use pocketmine\network\mcpe\protocol\serializer\ItemTypeDictionary; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\types\GameMode as ProtocolGameMode; use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; use pocketmine\network\mcpe\protocol\types\inventory\ItemStackExtraData; @@ -312,7 +313,7 @@ class TypeConverter{ $extraData = $id === $this->shieldRuntimeId ? new ItemStackExtraDataShield($nbt, canPlaceOn: [], canDestroy: [], blockingTick: 0) : new ItemStackExtraData($nbt, canPlaceOn: [], canDestroy: []); - $extraDataSerializer = PacketSerializer::encoder(); + $extraDataSerializer = new ByteBufferWriter(); $extraData->write($extraDataSerializer); return new ItemStack( @@ -320,7 +321,7 @@ class TypeConverter{ $meta, $itemStack->getCount(), $blockRuntimeId ?? ItemTranslator::NO_BLOCK_RUNTIME_ID, - $extraDataSerializer->getBuffer(), + $extraDataSerializer->getData(), ); } @@ -359,7 +360,7 @@ class TypeConverter{ } public function deserializeItemStackExtraData(string $extraData, int $id) : ItemStackExtraData{ - $extraDataDeserializer = PacketSerializer::decoder($extraData, 0); + $extraDataDeserializer = new ByteBufferReader($extraData); return $id === $this->shieldRuntimeId ? ItemStackExtraDataShield::read($extraDataDeserializer) : ItemStackExtraData::read($extraDataDeserializer); diff --git a/src/network/mcpe/encryption/EncryptionContext.php b/src/network/mcpe/encryption/EncryptionContext.php index 8c0b9bf74..6d76a84ad 100644 --- a/src/network/mcpe/encryption/EncryptionContext.php +++ b/src/network/mcpe/encryption/EncryptionContext.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\encryption; use Crypto\Cipher; -use pocketmine\utils\Binary; +use pmmp\encoding\LE; use function bin2hex; use function openssl_digest; use function openssl_error_string; @@ -104,7 +104,7 @@ class EncryptionContext{ } private function calculateChecksum(int $counter, string $payload) : string{ - $hash = openssl_digest(Binary::writeLLong($counter) . $payload . $this->key, self::CHECKSUM_ALGO, true); + $hash = openssl_digest(LE::packUnsignedLong($counter) . $payload . $this->key, self::CHECKSUM_ALGO, true); if($hash === false){ throw new \RuntimeException("openssl_digest() error: " . openssl_error_string()); } diff --git a/src/network/mcpe/serializer/ChunkSerializer.php b/src/network/mcpe/serializer/ChunkSerializer.php index 9120f34a7..230ff4a94 100644 --- a/src/network/mcpe/serializer/ChunkSerializer.php +++ b/src/network/mcpe/serializer/ChunkSerializer.php @@ -23,16 +23,16 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\serializer; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\VarInt; use pocketmine\block\tile\Spawnable; use pocketmine\data\bedrock\BiomeIds; use pocketmine\data\bedrock\LegacyBiomeIdToStringIdMap; use pocketmine\nbt\TreeRoot; use pocketmine\network\mcpe\convert\BlockTranslator; use pocketmine\network\mcpe\protocol\serializer\NetworkNbtSerializer; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\types\DimensionIds; -use pocketmine\utils\Binary; -use pocketmine\utils\BinaryStream; use pocketmine\world\format\Chunk; use pocketmine\world\format\PalettedBlockArray; use pocketmine\world\format\SubChunk; @@ -84,7 +84,7 @@ final class ChunkSerializer{ * @phpstan-param DimensionIds::* $dimensionId */ public static function serializeFullChunk(Chunk $chunk, int $dimensionId, BlockTranslator $blockTranslator, ?string $tiles = null) : string{ - $stream = PacketSerializer::encoder(); + $stream = new ByteBufferWriter(); $subChunkCount = self::getSubChunkCount($chunk, $dimensionId); $writtenCount = 0; @@ -100,37 +100,34 @@ final class ChunkSerializer{ self::serializeBiomePalette($chunk->getSubChunk($y)->getBiomeArray(), $biomeIdMap, $stream); } - $stream->putByte(0); //border block array count + Byte::writeUnsigned($stream, 0); //border block array count //Border block entry format: 1 byte (4 bits X, 4 bits Z). These are however useless since they crash the regular client. if($tiles !== null){ - $stream->put($tiles); + $stream->writeByteArray($tiles); }else{ - $stream->put(self::serializeTiles($chunk)); + $stream->writeByteArray(self::serializeTiles($chunk)); } - return $stream->getBuffer(); + return $stream->getData(); } - public static function serializeSubChunk(SubChunk $subChunk, BlockTranslator $blockTranslator, PacketSerializer $stream, bool $persistentBlockStates) : void{ + public static function serializeSubChunk(SubChunk $subChunk, BlockTranslator $blockTranslator, ByteBufferWriter $stream, bool $persistentBlockStates) : void{ $layers = $subChunk->getBlockLayers(); - $stream->putByte(8); //version + Byte::writeUnsigned($stream, 8); //version - $stream->putByte(count($layers)); + Byte::writeUnsigned($stream, count($layers)); $blockStateDictionary = $blockTranslator->getBlockStateDictionary(); foreach($layers as $blocks){ $bitsPerBlock = $blocks->getBitsPerBlock(); $words = $blocks->getWordArray(); - $stream->putByte(($bitsPerBlock << 1) | ($persistentBlockStates ? 0 : 1)); - $stream->put($words); + Byte::writeUnsigned($stream, ($bitsPerBlock << 1) | ($persistentBlockStates ? 0 : 1)); + $stream->writeByteArray($words); $palette = $blocks->getPalette(); if($bitsPerBlock !== 0){ - //these LSHIFT by 1 uvarints are optimizations: the client expects zigzag varints here - //but since we know they are always unsigned, we can avoid the extra fcall overhead of - //zigzag and just shift directly. - $stream->putUnsignedVarInt(count($palette) << 1); //yes, this is intentionally zigzag + VarInt::writeSignedInt($stream, count($palette)); //yes, this is intentionally zigzag } if($persistentBlockStates){ $nbtSerializer = new NetworkNbtSerializer(); @@ -141,46 +138,43 @@ final class ChunkSerializer{ $state = $blockTranslator->getFallbackStateData(); } - $stream->put($nbtSerializer->write(new TreeRoot($state->toNbt()))); + $stream->writeByteArray($nbtSerializer->write(new TreeRoot($state->toNbt()))); } }else{ + //we would use writeSignedIntArray() here, but the gains of writing in batch are negated by the cost of + //allocating a temporary array for the mapped palette IDs, especially for small palettes foreach($palette as $p){ - $stream->put(Binary::writeUnsignedVarInt($blockTranslator->internalIdToNetworkId($p) << 1)); + VarInt::writeSignedInt($stream, $blockTranslator->internalIdToNetworkId($p)); } } } } - private static function serializeBiomePalette(PalettedBlockArray $biomePalette, LegacyBiomeIdToStringIdMap $biomeIdMap, PacketSerializer $stream) : void{ + private static function serializeBiomePalette(PalettedBlockArray $biomePalette, LegacyBiomeIdToStringIdMap $biomeIdMap, ByteBufferWriter $stream) : void{ $biomePaletteBitsPerBlock = $biomePalette->getBitsPerBlock(); - $stream->putByte(($biomePaletteBitsPerBlock << 1) | 1); //the last bit is non-persistence (like for blocks), though it has no effect on biomes since they always use integer IDs - $stream->put($biomePalette->getWordArray()); + Byte::writeUnsigned($stream, ($biomePaletteBitsPerBlock << 1) | 1); //the last bit is non-persistence (like for blocks), though it has no effect on biomes since they always use integer IDs + $stream->writeByteArray($biomePalette->getWordArray()); - //these LSHIFT by 1 uvarints are optimizations: the client expects zigzag varints here - //but since we know they are always unsigned, we can avoid the extra fcall overhead of - //zigzag and just shift directly. $biomePaletteArray = $biomePalette->getPalette(); if($biomePaletteBitsPerBlock !== 0){ - $stream->putUnsignedVarInt(count($biomePaletteArray) << 1); + VarInt::writeSignedInt($stream, count($biomePaletteArray)); } foreach($biomePaletteArray as $p){ - if($biomeIdMap->legacyToString($p) === null){ - //make sure we aren't sending bogus biomes - the 1.18.0 client crashes if we do this - $p = BiomeIds::OCEAN; - } - $stream->put(Binary::writeUnsignedVarInt($p << 1)); + //we would use writeSignedIntArray() here, but the gains of writing in batch are negated by the cost of + //allocating a temporary array for the mapped palette IDs, especially for small palettes + VarInt::writeSignedInt($stream, $biomeIdMap->legacyToString($p) !== null ? $p : BiomeIds::OCEAN); } } public static function serializeTiles(Chunk $chunk) : string{ - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); foreach($chunk->getTiles() as $tile){ if($tile instanceof Spawnable){ - $stream->put($tile->getSerializedSpawnCompound()->getEncodedNbt()); + $stream->writeByteArray($tile->getSerializedSpawnCompound()->getEncodedNbt()); } } - return $stream->getBuffer(); + return $stream->getData(); } } diff --git a/src/network/query/QueryHandler.php b/src/network/query/QueryHandler.php index 940f64f18..41b633421 100644 --- a/src/network/query/QueryHandler.php +++ b/src/network/query/QueryHandler.php @@ -27,16 +27,16 @@ declare(strict_types=1); */ namespace pocketmine\network\query; +use pmmp\encoding\BE; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\DataDecodeException; use pocketmine\network\AdvancedNetworkInterface; use pocketmine\network\RawPacketHandler; use pocketmine\Server; -use pocketmine\utils\Binary; -use pocketmine\utils\BinaryDataException; -use pocketmine\utils\BinaryStream; -use function chr; use function hash; use function random_bytes; -use function strlen; use function substr; class QueryHandler implements RawPacketHandler{ @@ -80,51 +80,53 @@ class QueryHandler implements RawPacketHandler{ } public static function getTokenString(string $token, string $salt) : int{ - return Binary::readInt(substr(hash("sha512", $salt . ":" . $token, true), 7, 4)); + return BE::unpackSignedInt(substr(hash("sha512", $salt . ":" . $token, true), 7, 4)); } public function handle(AdvancedNetworkInterface $interface, string $address, int $port, string $packet) : bool{ try{ - $stream = new BinaryStream($packet); - $header = $stream->get(2); + $stream = new ByteBufferReader($packet); + $header = $stream->readByteArray(2); if($header !== "\xfe\xfd"){ //TODO: have this filtered by the regex filter we installed above return false; } - $packetType = $stream->getByte(); - $sessionID = $stream->getInt(); + $packetType = Byte::readUnsigned($stream); + $sessionID = BE::readUnsignedInt($stream); switch($packetType){ case self::HANDSHAKE: //Handshake - $reply = chr(self::HANDSHAKE); - $reply .= Binary::writeInt($sessionID); - $reply .= self::getTokenString($this->token, $address) . "\x00"; + $writer = new ByteBufferWriter(); + Byte::writeUnsigned($writer, self::HANDSHAKE); + BE::writeUnsignedInt($writer, $sessionID); + $writer->writeByteArray(self::getTokenString($this->token, $address) . "\x00"); - $interface->sendRawPacket($address, $port, $reply); + $interface->sendRawPacket($address, $port, $writer->getData()); return true; case self::STATISTICS: //Stat - $token = $stream->getInt(); + $token = BE::readUnsignedInt($stream); if($token !== ($t1 = self::getTokenString($this->token, $address)) && $token !== ($t2 = self::getTokenString($this->lastToken, $address))){ $this->logger->debug("Bad token $token from $address $port, expected $t1 or $t2"); return true; } - $reply = chr(self::STATISTICS); - $reply .= Binary::writeInt($sessionID); + $writer = new ByteBufferWriter(); + Byte::writeUnsigned($writer, self::STATISTICS); + BE::writeUnsignedInt($writer, $sessionID); - $remaining = $stream->getRemaining(); - if(strlen($remaining) === 4){ //TODO: check this! according to the spec, this should always be here and always be FF FF FF 01 - $reply .= $this->server->getQueryInformation()->getLongQuery(); + $remaining = $stream->getUnreadLength(); + if($remaining === 4){ //TODO: check this! according to the spec, this should always be here and always be FF FF FF 01 + $writer->writeByteArray($this->server->getQueryInformation()->getLongQuery()); }else{ - $reply .= $this->server->getQueryInformation()->getShortQuery(); + $writer->writeByteArray($this->server->getQueryInformation()->getShortQuery()); } - $interface->sendRawPacket($address, $port, $reply); + $interface->sendRawPacket($address, $port, $writer->getData()); return true; default: return false; } - }catch(BinaryDataException $e){ + }catch(DataDecodeException $e){ $this->logger->debug("Bad packet from $address $port: " . $e->getMessage()); return false; } diff --git a/src/network/query/QueryInfo.php b/src/network/query/QueryInfo.php index 0bf5b4f65..30aba5347 100644 --- a/src/network/query/QueryInfo.php +++ b/src/network/query/QueryInfo.php @@ -23,11 +23,11 @@ declare(strict_types=1); namespace pocketmine\network\query; +use pmmp\encoding\LE; use pocketmine\player\GameMode; use pocketmine\player\Player; use pocketmine\plugin\Plugin; use pocketmine\Server; -use pocketmine\utils\Binary; use pocketmine\utils\Utils; use pocketmine\YmlServerProperties; use function array_map; @@ -236,6 +236,6 @@ final class QueryInfo{ } public function getShortQuery() : string{ - return $this->shortQueryCache ?? ($this->shortQueryCache = $this->serverName . "\x00" . $this->gametype . "\x00" . $this->map . "\x00" . $this->numPlayers . "\x00" . $this->maxPlayers . "\x00" . Binary::writeLShort($this->port) . $this->ip . "\x00"); + return $this->shortQueryCache ?? ($this->shortQueryCache = $this->serverName . "\x00" . $this->gametype . "\x00" . $this->map . "\x00" . $this->numPlayers . "\x00" . $this->maxPlayers . "\x00" . LE::packUnsignedShort($this->port) . $this->ip . "\x00"); } } diff --git a/src/world/format/io/FastChunkSerializer.php b/src/world/format/io/FastChunkSerializer.php index 35a8ff42f..a186ec07f 100644 --- a/src/world/format/io/FastChunkSerializer.php +++ b/src/world/format/io/FastChunkSerializer.php @@ -23,8 +23,10 @@ declare(strict_types=1); namespace pocketmine\world\format\io; -use pocketmine\utils\Binary; -use pocketmine\utils\BinaryStream; +use pmmp\encoding\BE; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; use pocketmine\world\format\Chunk; use pocketmine\world\format\PalettedBlockArray; use pocketmine\world\format\SubChunk; @@ -45,15 +47,15 @@ final class FastChunkSerializer{ //NOOP } - private static function serializePalettedArray(BinaryStream $stream, PalettedBlockArray $array) : void{ + private static function serializePalettedArray(ByteBufferWriter $stream, PalettedBlockArray $array) : void{ $wordArray = $array->getWordArray(); $palette = $array->getPalette(); - $stream->putByte($array->getBitsPerBlock()); - $stream->put($wordArray); + Byte::writeUnsigned($stream, $array->getBitsPerBlock()); + $stream->writeByteArray($wordArray); $serialPalette = pack("L*", ...$palette); - $stream->putInt(strlen($serialPalette)); - $stream->put($serialPalette); + BE::writeUnsignedInt($stream, strlen($serialPalette)); + $stream->writeByteArray($serialPalette); } /** @@ -61,21 +63,20 @@ final class FastChunkSerializer{ * TODO: tiles and entities */ public static function serializeTerrain(Chunk $chunk) : string{ - $stream = new BinaryStream(); - $stream->putByte( - ($chunk->isPopulated() ? self::FLAG_POPULATED : 0) - ); + $stream = new ByteBufferWriter(); + Byte::writeUnsigned($stream, ($chunk->isPopulated() ? self::FLAG_POPULATED : 0)); //subchunks $subChunks = $chunk->getSubChunks(); $count = count($subChunks); - $stream->putByte($count); + Byte::writeUnsigned($stream, $count); foreach($subChunks as $y => $subChunk){ - $stream->putByte($y); - $stream->putInt($subChunk->getEmptyBlockId()); + Byte::writeSigned($stream, $y); + BE::writeUnsignedInt($stream, $subChunk->getEmptyBlockId()); + $layers = $subChunk->getBlockLayers(); - $stream->putByte(count($layers)); + Byte::writeUnsigned($stream, count($layers)); foreach($layers as $blocks){ self::serializePalettedArray($stream, $blocks); } @@ -83,14 +84,15 @@ final class FastChunkSerializer{ } - return $stream->getBuffer(); + return $stream->getData(); } - private static function deserializePalettedArray(BinaryStream $stream) : PalettedBlockArray{ - $bitsPerBlock = $stream->getByte(); - $words = $stream->get(PalettedBlockArray::getExpectedWordArraySize($bitsPerBlock)); + private static function deserializePalettedArray(ByteBufferReader $stream) : PalettedBlockArray{ + $bitsPerBlock = Byte::readUnsigned($stream); + $words = $stream->readByteArray(PalettedBlockArray::getExpectedWordArraySize($bitsPerBlock)); + $paletteSize = BE::readUnsignedInt($stream); /** @var int[] $unpackedPalette */ - $unpackedPalette = unpack("L*", $stream->get($stream->getInt())); //unpack() will never fail here + $unpackedPalette = unpack("L*", $stream->readByteArray($paletteSize)); //unpack() will never fail here $palette = array_values($unpackedPalette); return PalettedBlockArray::fromData($bitsPerBlock, $words, $palette); @@ -100,20 +102,21 @@ final class FastChunkSerializer{ * Deserializes a fast-serialized chunk */ public static function deserializeTerrain(string $data) : Chunk{ - $stream = new BinaryStream($data); + $stream = new ByteBufferReader($data); - $flags = $stream->getByte(); + $flags = Byte::readUnsigned($stream); $terrainPopulated = (bool) ($flags & self::FLAG_POPULATED); $subChunks = []; - $count = $stream->getByte(); + $count = Byte::readUnsigned($stream); for($subCount = 0; $subCount < $count; ++$subCount){ - $y = Binary::signByte($stream->getByte()); - $airBlockId = $stream->getInt(); + $y = Byte::readSigned($stream); + //TODO: why the heck are we using big-endian here? + $airBlockId = BE::readUnsignedInt($stream); $layers = []; - for($i = 0, $layerCount = $stream->getByte(); $i < $layerCount; ++$i){ + for($i = 0, $layerCount = Byte::readUnsigned($stream); $i < $layerCount; ++$i){ $layers[] = self::deserializePalettedArray($stream); } $biomeArray = self::deserializePalettedArray($stream); diff --git a/tools/generate-bedrock-data-from-packets.php b/tools/generate-bedrock-data-from-packets.php index 01ff368ab..47411701a 100644 --- a/tools/generate-bedrock-data-from-packets.php +++ b/tools/generate-bedrock-data-from-packets.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\tools\generate_bedrock_data_from_packets; +use pmmp\encoding\ByteBufferReader; use pocketmine\crafting\json\FurnaceRecipeData; use pocketmine\crafting\json\ItemStackData; use pocketmine\crafting\json\PotionContainerChangeRecipeData; @@ -51,7 +52,6 @@ use pocketmine\network\mcpe\protocol\CreativeContentPacket; use pocketmine\network\mcpe\protocol\ItemRegistryPacket; use pocketmine\network\mcpe\protocol\PacketPool; use pocketmine\network\mcpe\protocol\serializer\ItemTypeDictionary; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\StartGamePacket; use pocketmine\network\mcpe\protocol\types\inventory\CreativeGroupEntry; use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; @@ -190,7 +190,7 @@ class ParserPacketHandler extends PacketHandler{ $rawExtraData = $itemStack->getRawExtraData(); if($rawExtraData !== ""){ - $decoder = PacketSerializer::decoder($rawExtraData, 0); + $decoder = new ByteBufferReader($rawExtraData); $extraData = $itemStringId === ItemTypeNames::SHIELD ? ItemStackExtraDataShield::read($decoder) : ItemStackExtraData::read($decoder); $nbt = $extraData->getNbt(); if($nbt !== null && count($nbt) > 0){ @@ -645,12 +645,13 @@ function main(array $argv) : int{ fwrite(STDERR, "Unknown packet on line " . ($lineNum + 1) . ": " . $parts[1]); continue; } - $serializer = PacketSerializer::decoder($raw, 0); + $serializer = new ByteBufferReader($raw); $pk->decode($serializer); $pk->handle($handler); - if(!$serializer->feof()){ - echo "Packet on line " . ($lineNum + 1) . ": didn't read all data from " . get_class($pk) . " (stopped at offset " . $serializer->getOffset() . " of " . strlen($serializer->getBuffer()) . " bytes): " . bin2hex($serializer->getRemaining()) . "\n"; + $remaining = strlen($serializer->getData()) - $serializer->getOffset(); + if($remaining > 0){ + echo "Packet on line " . ($lineNum + 1) . ": didn't read all data from " . get_class($pk) . " (stopped at offset " . $serializer->getOffset() . " of " . strlen($serializer->getData()) . " bytes): " . bin2hex($serializer->readByteArray($remaining)) . "\n"; } } return 0; From 5ca8e1502720c8d71bf837688ad0fcb9e773a0b4 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Thu, 25 Sep 2025 18:18:10 +0100 Subject: [PATCH 5/6] tidy --- src/Server.php | 3 +++ src/entity/projectile/Trident.php | 4 ++-- .../{TridentHitGroundSound.php => TridentHitBlockSound.php} | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) rename src/world/sound/{TridentHitGroundSound.php => TridentHitBlockSound.php} (95%) diff --git a/src/Server.php b/src/Server.php index 4f0fa4ce1..af9cbeda7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -1804,6 +1804,9 @@ class Server{ return $this->forceLanguage; } + /** + * @internal + */ public function getAuthKeyProvider() : AuthKeyProvider{ return $this->authKeyProvider; } diff --git a/src/entity/projectile/Trident.php b/src/entity/projectile/Trident.php index 73b3880ac..20a494e8d 100644 --- a/src/entity/projectile/Trident.php +++ b/src/entity/projectile/Trident.php @@ -38,8 +38,8 @@ use pocketmine\network\mcpe\protocol\types\entity\EntityIds; use pocketmine\network\mcpe\protocol\types\entity\EntityMetadataCollection; use pocketmine\network\mcpe\protocol\types\entity\EntityMetadataFlags; use pocketmine\player\Player; +use pocketmine\world\sound\TridentHitBlockSound; use pocketmine\world\sound\TridentHitEntitySound; -use pocketmine\world\sound\TridentHitGroundSound; class Trident extends Projectile{ @@ -119,7 +119,7 @@ class Trident extends Projectile{ protected function onHitBlock(Block $blockHit, RayTraceResult $hitResult) : void{ parent::onHitBlock($blockHit, $hitResult); $this->canCollide = true; - $this->broadcastSound(new TridentHitGroundSound()); + $this->broadcastSound(new TridentHitBlockSound()); } public function getItem() : Item{ diff --git a/src/world/sound/TridentHitGroundSound.php b/src/world/sound/TridentHitBlockSound.php similarity index 95% rename from src/world/sound/TridentHitGroundSound.php rename to src/world/sound/TridentHitBlockSound.php index 361381786..05cd34248 100644 --- a/src/world/sound/TridentHitGroundSound.php +++ b/src/world/sound/TridentHitBlockSound.php @@ -27,7 +27,7 @@ use pocketmine\math\Vector3; use pocketmine\network\mcpe\protocol\LevelSoundEventPacket; use pocketmine\network\mcpe\protocol\types\LevelSoundEvent; -class TridentHitGroundSound implements Sound{ +class TridentHitBlockSound implements Sound{ public function encode(Vector3 $pos) : array{ return [LevelSoundEventPacket::nonActorSound(LevelSoundEvent::ITEM_TRIDENT_HIT_GROUND, $pos, false)]; From 236fa062f04cfac3a54a9af5032dfd1a7fd2a454 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Thu, 25 Sep 2025 18:20:23 +0100 Subject: [PATCH 6/6] Prepare 5.34.0 release --- changelogs/5.34.md | 104 ++++++++++++++++++++++++++++++++++++++++++++ src/VersionInfo.php | 4 +- 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 changelogs/5.34.md diff --git a/changelogs/5.34.md b/changelogs/5.34.md new file mode 100644 index 000000000..f21e02e1c --- /dev/null +++ b/changelogs/5.34.md @@ -0,0 +1,104 @@ +# 5.34.0 +Released 26th September 2025. + +This is a minor feature release containing performance improvements, new gameplay features, new API additions and network changes. + +**Plugin compatibility:** Plugins for previous 5.x versions will run unchanged on this release, unless they use internal APIs, reflection, or packages like the `pocketmine\network\mcpe` or `pocketmine\data` namespace. +Do not update plugin minimum API versions unless you need new features added in this release. + +**WARNING: If your plugin uses the `pocketmine\network\mcpe` namespace, you're not shielded by API change constraints.** +Consider using the `mcpe-protocol` directive in `plugin.yml` as a constraint if you're using packets directly. + +## General +- PocketMine-MP now requires and uses [`pmmp/ext-encoding`](https://github.com/pmmp/ext-encoding) version `1.0.0`, a PHP extension designed to significantly improve performance of data encoding and decoding (@dktapps). + - This first pass only implements support in low-risk areas, such as network protocol and read-only data. Further integration will follow in future minor versions with additional performance improvements. + - While ext-encoding has been heavily tested, we can't be sure there won't be major issues once it reaches production. Please report any bugs you find on the GitHub issue tracker. +- New Bedrock OpenID authentication support has been implemented (@dries-c, @dktapps). + - This system fetches keys from an API provided by Microsoft. Therefore, your server must now have internet access to authenticate players. +- `/timings paste` now creates private reports by default on `timings.pmmp.io` (@dktapps). + - Private reports require an access token to view, so your timings reports can no longer be viewed by others just by guessing the ID. + - If you're using a custom timings host, be sure to update it to get support for this feature. + - The command will generate a warning in the console if the target timings host doesn't support private reports. + +## Performance +- Significantly improved performance of packet encoding and decoding using `ext-encoding` (@dktapps). +- Unnecessary NBT is now stripped from items before sending them over the network. This significantly improves performance when working with writable books, shulker boxes, etc. (@dktapps). +- Improved performance of item saving in `ItemSerializer` by avoiding slow `hasNamedTag()` call followed by `getNamedTag()` (both will rebuild the NBT) (@dktapps). + +## Gameplay +- Implemented basic Trident functionality (@IvanCraft623). +- Implemented Firework and Firework Star (@IvanCraft623). +- Editing the rear side of signs is now supported (@dktapps). +- Sneaking hitbox height has been adjusted to match vanilla (@Dasciam). + +## API +### General +- `pocketmine/nbt` version `1.2.0` is now used ([changelog](https://github.com/pmmp/NBT/releases/tag/1.2.0)). +- `pmmp/ext-encoding` version `1.0.0` is now required and used. + - This can be used as a faster alternative to `BinaryStream` and `Binary` in most use cases. However, please note that its API is very different, and it hasn't been battle-tested yet. + - A recent JetBrains IDE stub can be found in our [custom stubs repository](https://github.com/pmmp/phpstorm-stubs/blob/fork/encoding/encoding.php). + +### `pocketmine\block` +- The following API methods have been added: + - `public BaseSign->getFaceText(bool $frontFace) : SignText` + - `public BaseSign->setFaceText(bool $frontFace, SignText $text) : $this` + - `public BaseSign->updateFaceText(Player $author, bool $frontFace, SignText $text) : bool` - called by the network system when a player edits a sign, triggers `SignChangeEvent` etc. + - `protected BaseSign->getHitboxCenter() : Vector3` - returns the center of the sign's hitbox, used to decide which face of the sign the player is editing + - `protected BaseSign->getFacingDegrees() : float` (to become abstract in PM6) - returns the horizontal facing of the sign in degrees, used to decide which face of the sign the player is editing +- The following API methods have been deprecated: + - `public BaseSign->getText() : SignText` - use `getFaceText()` instead + - `public BaseSign->setText(SignText $text) : $this` - use `setFaceText()` instead + - `public BaseSign->updateText(Player $author, SignText $text) : bool` - use `updateFaceText()` instead + +### `pocketmine\entity` +- The following API classes have been added: + - `NeverSavedWithChunkEntity` - implement this instead of overriding `canSaveWithChunk()` if your entity will never need a save ID + - Used currently by `Player` and `FireworkRocket`. + - `animation\FireworkParticlesAnimation` + - `object\FireworkRocket` + - `projectile\Trident` +- The following API methods have been added: + - `public Living->getSneakOffset() : float` - returns how much the entity's hitbox is shortened and eye height lowered when sneaking (0 by default) + - `protected Projectile->despawnsOnEntityHit() : bool` - returns `true` by default, overridden by tridents (to be removed in a future major version in favour of cleaner BC-breaking methods) + +### `pocketmine\event\block` +- The following API methods have been added: + - `public SignChangeEvent->isFrontFace() : bool` - returns `true` if the front face of the sign is being edited, `false` for the rear face + +### `pocketmine\inventory\transaction` +- `InventoryTransaction` no longer shuffles actions before executing a transaction. + - This was intended to prevent dependency on weird client behaviour, but it is no longer necessary, as the order is now consistent since the introduction of the `ItemStackRequest` system. + +### `pocketmine\item` +- The following API classes have been added: + - `FireworkRocket` + - `FireworkRocketExplosion` + - `FireworkRocketType` (enum) + - `FireworkStar` + - `Trident` +- The following API methods have been added: + - `VanillaItems::FIREWORK_ROCKET() : FireworkRocket` + - `VanillaItems::FIREWORK_STAR() : FireworkStar` + - `VanillaItems::TRIDENT() : Trident` + +### `pocketmine\player` +- The following API methods have signature changes: + - `Player->openSignEditor()` now accepts an optional `bool $frontFace = true` parameter + +### `pocketmine\world\sound` +- The following API classes have been added: + - `FireworkCrackleSound` + - `FireworkExplosionSound` + - `FireworkLargeExplosionSound` + - `FireworkLaunchSound` + - `TridentHitEntitySound` + - `TridentHitBlockSound` + - `TridentThrowSound` + +## Internals +- Many low-risk data handling areas have been switched to use `ext-encoding`, including: + - Bedrock packets + - Bedrock chunk serialization + - `FastChunkSerializer` (used for transmitting chunks between threads) + - GS4 Query + - Auxiliary read-only data loading in the `pocketmine\data\bedrock` package diff --git a/src/VersionInfo.php b/src/VersionInfo.php index 944d6d84f..55495a959 100644 --- a/src/VersionInfo.php +++ b/src/VersionInfo.php @@ -31,8 +31,8 @@ use function str_repeat; final class VersionInfo{ public const NAME = "PocketMine-MP"; - public const BASE_VERSION = "5.33.3"; - public const IS_DEVELOPMENT_BUILD = true; + public const BASE_VERSION = "5.34.0"; + public const IS_DEVELOPMENT_BUILD = false; public const BUILD_CHANNEL = "stable"; /**