Compare commits

..

5 Commits

Author SHA1 Message Date
87ffb9ac89 stfu 2025-09-08 21:23:14 +01:00
82dceaaf38 Reworked a bunch of auth code 2025-09-08 21:20:55 +01:00
094196cbf8 Fix PHPStan 2025-09-07 00:23:54 +02:00
c5d017208d Rename variables from manager to provider 2025-09-06 23:46:11 +02:00
b3170913f7 Initial attempt to support new authentication 2025-09-06 23:44:25 +02:00
14 changed files with 620 additions and 227 deletions

View File

@ -36,7 +36,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": "~40.0.0+bedrock-1.21.100",
"pocketmine/bedrock-protocol": "dev-new-auth",
"pocketmine/binaryutils": "^0.2.1",
"pocketmine/callback-validator": "^1.0.2",
"pocketmine/color": "^0.3.0",

34
composer.lock generated
View File

@ -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": "27fee330bdcb6ea2373c57cdfb3bc22f",
"content-hash": "a631e67b7087fb41eeaa2cdf9ded59d7",
"packages": [
{
"name": "adhocore/json-comment",
@ -256,16 +256,16 @@
},
{
"name": "pocketmine/bedrock-protocol",
"version": "40.0.0+bedrock-1.21.100",
"version": "dev-new-auth",
"source": {
"type": "git",
"url": "https://github.com/pmmp/BedrockProtocol.git",
"reference": "5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca"
"reference": "cb617c0da8ab2b5e89922dce3b34579c75354d25"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca",
"reference": "5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca",
"url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/cb617c0da8ab2b5e89922dce3b34579c75354d25",
"reference": "cb617c0da8ab2b5e89922dce3b34579c75354d25",
"shasum": ""
},
"require": {
@ -296,9 +296,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/40.0.0+bedrock-1.21.100"
"source": "https://github.com/pmmp/BedrockProtocol/tree/new-auth"
},
"time": "2025-08-06T15:13:45+00:00"
"time": "2025-09-06T21:39:24+00:00"
},
{
"name": "pocketmine/binaryutils",
@ -818,20 +818,20 @@
},
{
"name": "ramsey/uuid",
"version": "4.9.1",
"version": "4.9.0",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
"reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
"reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0",
"reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0",
"shasum": ""
},
"require": {
"brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@ -890,9 +890,9 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.9.1"
"source": "https://github.com/ramsey/uuid/tree/4.9.0"
},
"time": "2025-09-04T20:59:21+00:00"
"time": "2025-06-25T14:20:11+00:00"
},
{
"name": "symfony/filesystem",
@ -2773,7 +2773,9 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"stability-flags": {
"pocketmine/bedrock-protocol": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
@ -2804,7 +2806,7 @@
"ext-zlib": ">=1.2.11",
"composer-runtime-api": "^2.0"
},
"platform-dev": {},
"platform-dev": [],
"platform-overrides": {
"php": "8.1.0"
},

View File

@ -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;
}

View File

@ -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 $signingKeyPem, 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
$signingKeyPem,
$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");
}
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)));
/**
* 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 parsePemPublicKey(string $pemKey) : \OpenSSLAsymmetricKey{
$signingKeyOpenSSL = openssl_pkey_get_public($pemKey);
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);
}
}

View File

@ -820,14 +820,14 @@ class NetworkSession{
}, $reason);
}
private function setAuthenticationStatus(bool $authenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPubKey) : void{
private function setAuthenticationStatus(bool $authenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPubKeyPem) : void{
if(!$this->connected){
return;
}
if($error === null){
if($authenticated && !($this->info instanceof XboxLivePlayerInfo)){
$error = "Expected XUID but none found";
}elseif($clientPubKey === null){
}elseif($clientPubKeyPem === null){
$error = "Missing client public key"; //failsafe
}
}
@ -903,7 +903,11 @@ class NetworkSession{
}
if(EncryptionContext::$ENABLED){
$this->server->getAsyncPool()->submitTask(new PrepareEncryptionTask($clientPubKey, function(string $encryptionKey, string $handshakeJwt) : void{
if($clientPubKeyPem === null){
//TODO: clean this mess up :(
throw new AssumptionFailedError("We already checked above that this is not null");
}
$this->server->getAsyncPool()->submitTask(new PrepareEncryptionTask($clientPubKeyPem, function(string $encryptionKey, string $handshakeJwt) : void{
if(!$this->connected){
return;
}

View File

@ -0,0 +1,164 @@
<?php
/*
*
* ____ _ _ __ __ _ __ __ ____
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* @author PocketMine Team
* @link http://www.pocketmine.net/
*
*
*/
declare(strict_types=1);
namespace pocketmine\network\mcpe\auth;
use pocketmine\network\mcpe\JwtException;
use pocketmine\network\mcpe\JwtUtils;
use pocketmine\network\mcpe\protocol\types\login\auth\AuthServiceKey;
use pocketmine\promise\Promise;
use pocketmine\promise\PromiseResolver;
use pocketmine\scheduler\AsyncPool;
use pocketmine\utils\AssumptionFailedError;
use function array_keys;
use function count;
use function implode;
use function time;
class AuthKeyProvider{
private const ALLOWED_REFRESH_INTERVAL = 30 * 60; // 30 minutes
private ?AuthKeyring $keyring = null;
/** @phpstan-var PromiseResolver<AuthKeyring> */
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<array{string, string}>
*/
public function getKey(string $keyId) : Promise{
/** @phpstan-var PromiseResolver<array{string, string}> $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<array{string, string}> $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<string, AuthServiceKey>|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;
}
$pemKey = JwtUtils::derPublicKeyToPem(JwtUtils::rsaPublicKeyModExpToDer($keyModel->n, $keyModel->e));
//make sure the key is valid
try{
JwtUtils::parsePemPublicKey($pemKey);
}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] = $pemKey;
}
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<AuthKeyring>
*/
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<AuthKeyring> $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();
}
}

View File

@ -0,0 +1,45 @@
<?php
/*
*
* ____ _ _ __ __ _ __ __ ____
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* @author PocketMine Team
* @link http://www.pocketmine.net/
*
*
*/
declare(strict_types=1);
namespace pocketmine\network\mcpe\auth;
final class AuthKeyring{
/**
* @param string[] $keys
* @phpstan-param array<string, string> $keys
*/
public function __construct(
private string $issuer,
private array $keys
){}
public function getIssuer() : string{ return $this->issuer; }
/**
* Returns a PEM public key associated with the given key ID
*/
public function getKey(string $keyId) : ?string{
return $this->keys[$keyId] ?? null;
}
}

View File

@ -0,0 +1,209 @@
<?php
/*
*
* ____ _ _ __ __ _ __ __ ____
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* @author PocketMine Team
* @link http://www.pocketmine.net/
*
*
*/
declare(strict_types=1);
namespace pocketmine\network\mcpe\auth;
use pocketmine\network\mcpe\protocol\ProtocolInfo;
use pocketmine\network\mcpe\protocol\types\login\auth\AuthServiceKey;
use pocketmine\network\mcpe\protocol\types\login\auth\AuthServiceOpenIdConfiguration;
use pocketmine\network\mcpe\protocol\types\login\services\MinecraftServicesDiscovery;
use pocketmine\scheduler\AsyncTask;
use pocketmine\thread\NonThreadSafeValue;
use pocketmine\utils\Internet;
use function gettype;
use function is_array;
use function is_object;
use function json_decode;
use const JSON_THROW_ON_ERROR;
class FetchAuthKeysTask extends AsyncTask{
private const KEYS_ON_COMPLETION = "completion";
private const MINECRAFT_SERVICES_DISCOVERY_URL = "https://client.discovery.minecraft-services.net/api/v1.0/discovery/MinecraftPE/builds/" . ProtocolInfo::MINECRAFT_VERSION_NETWORK;
private const AUTHORIZATION_SERVICE_URI_FALLBACK = "https://authorization.franchise.minecraft-services.net";
private const AUTHORIZATION_SERVICE_OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration";
private const AUTHORIZATION_SERVICE_KEYS_PATH = "/.well-known/keys";
/** @phpstan-var ?NonThreadSafeValue<array<string, AuthServiceKey>> */
private ?NonThreadSafeValue $keys = null;
private string $issuer;
/** @phpstan-var ?NonThreadSafeValue<non-empty-array<string>> */
private ?NonThreadSafeValue $errors = null;
/**
* @phpstan-param \Closure(?array<string, AuthServiceKey> $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<string, AuthServiceKey> 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());
}
}

View File

@ -27,29 +27,20 @@ use pocketmine\lang\KnownTranslationFactory;
use pocketmine\lang\Translatable;
use pocketmine\network\mcpe\JwtException;
use pocketmine\network\mcpe\JwtUtils;
use pocketmine\network\mcpe\protocol\types\login\JwtBodyRfc7519;
use pocketmine\network\mcpe\protocol\types\login\JwtChainLinkBody;
use pocketmine\network\mcpe\protocol\types\login\JwtHeader;
use pocketmine\scheduler\AsyncTask;
use pocketmine\thread\NonThreadSafeValue;
use function base64_decode;
use function igbinary_serialize;
use function igbinary_unserialize;
use function time;
class ProcessLoginTask extends AsyncTask{
private const TLS_KEY_ON_COMPLETION = "completion";
/**
* New Mojang root auth key. Mojang notified third-party developers of this change prior to the release of 1.20.0.
* Expectations were that this would be used starting a "couple of weeks" after the release, but as of 2023-07-01,
* it has not yet been deployed.
*/
public const MOJANG_ROOT_PUBLIC_KEY = "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAECRXueJeTDqNRRgJi/vlRufByu/2G0i2Ebt6YMar5QX/R0DIIyrJMcUpruK4QveTfJSTp3Shlq4Gk34cD/4GUWwkv0DVuzeuB+tXija7HBxii03NHDbPAD0AKnLr2wdAp";
public const MOJANG_AUDIENCE = "api://auth-minecraft-services/multiplayer";
private const CLOCK_DRIFT_MAX = 60;
private string $chain;
/**
* Whether the keychain signatures were validated correctly. This will be set to an error message if any link in the
* keychain is invalid for whatever reason (bad signature, not in nbf-exp window, etc). If this is non-null, the
@ -63,25 +54,25 @@ class ProcessLoginTask extends AsyncTask{
* root public key.
*/
private bool $authenticated = false;
private ?string $clientPublicKey = null;
private ?string $clientPublicKeyPem = 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 $jwt,
private string $issuer,
private string $mojangPublicKeyPem,
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->clientPublicKeyPem = $this->validateChain();
$this->error = null;
}catch(VerifyLoginException $e){
$disconnectMessage = $e->getDisconnectMessage();
@ -90,81 +81,43 @@ class ProcessLoginTask extends AsyncTask{
}
private function validateChain() : string{
/** @var string[] $chain */
$chain = igbinary_unserialize($this->chain);
$claims = $this->validateToken($this->jwt, $this->mojangPublicKeyPem, isEcKey: false, bodyClass: JwtChainLinkBody::class);
//validateToken will throw if the JWT is not valid
$this->authenticated = true;
$currentKey = null;
$first = true;
foreach($chain as $jwt){
$this->validateToken($jwt, $currentKey, $first);
if($first){
$first = false;
}
$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
$clientPublicKeyPem = JwtUtils::derPublicKeyToPem($clientDerKey);
$this->validateToken($this->clientDataJwt, $clientPublicKeyPem, isEcKey: true, bodyClass: JwtBodyRfc7519::class);
/** @var string $clientKey */
$clientKey = $currentKey;
$this->validateToken($this->clientDataJwt, $currentKey);
return $clientKey;
return $clientPublicKeyPem;
}
/**
* @phpstan-template TBody of JwtBodyRfc7519
* @phpstan-param class-string<TBody> $bodyClass
* @phpstan-return TBody
*
* @throws VerifyLoginException if errors are encountered
*/
private function validateToken(string $jwt, ?string &$currentPublicKey, bool $first = false) : void{
private function validateToken(string $jwt, string $signingKeyPem, bool $isEcKey, string $bodyClass) : JwtBodyRfc7519{
try{
[$headersArray, $claimsArray, ] = JwtUtils::parse($jwt);
[, $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 JwtHeader $headers */
$headers = $mapper->map($headersArray, new JwtHeader());
}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)){
if(!JwtUtils::verify($jwt, $signingKeyPem, $isEcKey)){
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;
@ -172,12 +125,20 @@ class ProcessLoginTask extends AsyncTask{
$mapper->bEnforceMapType = false;
$mapper->bRemoveUndefinedAttributes = true;
try{
/** @var JwtChainLinkBody $claims */
$claims = $mapper->map($claimsArray, new JwtChainLinkBody());
//nasty dynamic new for JsonMapper
$claims = $mapper->map($claimsArray, new $bodyClass());
}catch(\JsonMapper_Exception $e){
throw new VerifyLoginException("Invalid chain link body: " . $e->getMessage(), null, 0, $e);
}
if(isset($claims->iss) && $claims->iss !== $this->issuer){
throw new VerifyLoginException("Invalid JWT issuer");
}
if(isset($claims->aud) && $claims->aud !== self::MOJANG_AUDIENCE){
throw new VerifyLoginException("Invalid JWT audience");
}
$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());
@ -187,19 +148,7 @@ class ProcessLoginTask extends AsyncTask{
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
}
return $claims;
}
public function onCompletion() : void{
@ -208,6 +157,6 @@ class ProcessLoginTask extends AsyncTask{
* @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);
$callback($this->authenticated, $this->authRequired, $this->error instanceof NonThreadSafeValue ? $this->error->deserialize() : $this->error, $this->clientPublicKeyPem);
}
}

View File

@ -30,6 +30,7 @@ use function bin2hex;
use function gmp_init;
use function gmp_strval;
use function hex2bin;
use function is_string;
use function openssl_digest;
use function openssl_error_string;
use function openssl_pkey_derive;
@ -54,9 +55,11 @@ final class EncryptionUtils{
}
}
public static function generateSharedSecret(\OpenSSLAsymmetricKey $localPriv, \OpenSSLAsymmetricKey $remotePub) : \GMP{
public static function generateSharedSecret(\OpenSSLAsymmetricKey $localPriv, \OpenSSLAsymmetricKey|string $remotePub) : \GMP{
self::validateKey($localPriv);
self::validateKey($remotePub);
if(!is_string($remotePub)){
self::validateKey($remotePub);
}
$hexSecret = openssl_pkey_derive($remotePub, $localPriv, 48);
if($hexSecret === false){
throw new \InvalidArgumentException("Failed to derive shared secret: " . openssl_error_string());

View File

@ -23,7 +23,6 @@ declare(strict_types=1);
namespace pocketmine\network\mcpe\encryption;
use pocketmine\network\mcpe\JwtUtils;
use pocketmine\scheduler\AsyncTask;
use pocketmine\utils\AssumptionFailedError;
use function igbinary_serialize;
@ -48,7 +47,7 @@ class PrepareEncryptionTask extends AsyncTask{
* @phpstan-param \Closure(string $encryptionKey, string $handshakeJwt) : void $onCompletion
*/
public function __construct(
private string $clientPub,
private string $clientPublicKeyPem,
\Closure $onCompletion
){
if(self::$SERVER_PRIVATE_KEY === null){
@ -68,8 +67,7 @@ class PrepareEncryptionTask extends AsyncTask{
$serverPrivDetails = igbinary_unserialize($this->serverPrivateKey);
$serverPriv = openssl_pkey_new($serverPrivDetails);
if($serverPriv === false) throw new AssumptionFailedError("Failed to restore server signing key from details");
$clientPub = JwtUtils::parseDerPublicKey($this->clientPub);
$sharedSecret = EncryptionUtils::generateSharedSecret($serverPriv, $clientPub);
$sharedSecret = EncryptionUtils::generateSharedSecret($serverPriv, $this->clientPublicKeyPem);
$salt = random_bytes(16);
$this->aesKey = EncryptionUtils::generateKey($sharedSecret, $salt);

View File

@ -32,12 +32,12 @@ use pocketmine\network\mcpe\JwtException;
use pocketmine\network\mcpe\JwtUtils;
use pocketmine\network\mcpe\NetworkSession;
use pocketmine\network\mcpe\protocol\LoginPacket;
use pocketmine\network\mcpe\protocol\types\login\AuthenticationData;
use pocketmine\network\mcpe\protocol\types\login\AuthenticationInfo;
use pocketmine\network\mcpe\protocol\types\login\AuthenticationType;
use pocketmine\network\mcpe\protocol\types\login\ClientData;
use pocketmine\network\mcpe\protocol\types\login\ClientDataToSkinDataHelper;
use pocketmine\network\mcpe\protocol\types\login\JwtChain;
use pocketmine\network\mcpe\protocol\types\login\JwtChainLinkBody;
use pocketmine\network\mcpe\protocol\types\login\JwtHeader;
use pocketmine\network\PacketHandlingException;
use pocketmine\player\Player;
use pocketmine\player\PlayerInfo;
@ -45,9 +45,9 @@ use pocketmine\player\XboxLivePlayerInfo;
use pocketmine\Server;
use Ramsey\Uuid\Uuid;
use function gettype;
use function is_array;
use function is_object;
use function json_decode;
use function md5;
use const JSON_THROW_ON_ERROR;
/**
@ -67,10 +67,26 @@ class LoginPacketHandler extends PacketHandler{
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->parseHeader($headerArray);
$claims = $this->parseBody($claimsArray);
$legacyUuid = Uuid::fromBytes(md5("pocket-auth-1-xuid:" . $claims->xid, binary: true));
$username = $claims->xname;
$xuid = $claims->xid;
}elseif($authInfo->AuthenticationType === AuthenticationType::SELF_SIGNED->value){
throw new PacketHandlingException("TODO NOT SUPPORTED");
}else{
throw new PacketHandlingException("Unsupported authentication type: $authInfo->AuthenticationType");
}
if(!Player::isValidUserName($username)){
$this->session->disconnectWithError(KnownTranslationFactory::disconnectionScreen_invalidName());
return true;
@ -89,29 +105,22 @@ class LoginPacketHandler extends PacketHandler{
return true;
}
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);
@ -147,7 +156,7 @@ class LoginPacketHandler extends PacketHandler{
return true;
}
$this->processLogin($authInfo->Token, AuthenticationType::from($authInfo->AuthenticationType), $jwtChain->chain, $packet->clientDataJwt, $ev->isAuthRequired());
$this->processXboxLogin($authInfo->Token, $header->kid, $packet->clientDataJwt, $ev->isAuthRequired());
return true;
}
@ -162,13 +171,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 +184,33 @@ class LoginPacketHandler extends PacketHandler{
}
/**
* @param array<string, mixed> $headerArray
* @throws PacketHandlingException
*/
protected function parseJwtChain(string $chainDataJwt) : JwtChain{
protected function parseHeader(array $headerArray) : JwtHeader{
$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 JwtChain());
$header = $mapper->map($headerArray, new JwtHeader());
}catch(\JsonMapper_Exception $e){
throw PacketHandlingException::wrap($e);
}
return $clientData;
return $header;
}
/**
* @param array<string, mixed> $bodyArray
* @throws PacketHandlingException
*/
protected function fetchAuthData(JwtChain $chain) : AuthenticationData{
/** @var AuthenticationData|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");
}
protected function parseBody(array $bodyArray) : JwtChainLinkBody{
$mapper = $this->defaultJsonMapper();
$mapper->bRemoveUndefinedAttributes = true;
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 AuthenticationData $extraData */
$extraData = $mapper->map($claims["extraData"], new AuthenticationData());
}catch(\JsonMapper_Exception $e){
throw PacketHandlingException::wrap($e);
}
}
try{
$header = $mapper->map($bodyArray, new JwtChainLinkBody());
}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 +223,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 +236,28 @@ 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 processXboxLogin(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 ProcessLoginTask($token, $issuer, $mojangPublicKeyPem, $clientData, $authRequired, $this->authCallback));
},
fn() => ($this->authCallback)(false, $authRequired, "Unrecognized authentication key ID: $keyId", null)
);
}
private function defaultJsonMapper() : \JsonMapper{
$mapper = new \JsonMapper();
$mapper->bExceptionOnMissingData = true;
$mapper->bExceptionOnUndefinedProperty = true;
$mapper->bStrictObjectTypeChecking = true;
$mapper->bEnforceMapType = false;
return $mapper;
}
}

View File

@ -792,12 +792,6 @@ parameters:
count: 1
path: ../../../src/network/mcpe/NetworkSession.php
-
message: '#^Parameter \#1 \$clientPub of class pocketmine\\network\\mcpe\\encryption\\PrepareEncryptionTask constructor expects string, string\|null given\.$#'
identifier: argument.type
count: 1
path: ../../../src/network/mcpe/NetworkSession.php
-
message: '#^Parameter \#1 \$for of method pocketmine\\network\\mcpe\\NetworkSession\:\:syncAbilities\(\) expects pocketmine\\player\\Player, pocketmine\\player\\Player\|null given\.$#'
identifier: argument.type
@ -871,8 +865,8 @@ parameters:
path: ../../../src/network/mcpe/NetworkSession.php
-
message: '#^Property pocketmine\\network\\mcpe\\auth\\ProcessLoginTask\:\:\$chain \(string\) does not accept string\|null\.$#'
identifier: assign.propertyType
message: '#^Dynamic new is not allowed\.$#'
identifier: pocketmine.new.dynamic
count: 1
path: ../../../src/network/mcpe/auth/ProcessLoginTask.php