From b5045a76de1ea7e9e2ef74fd5ddbe43e89587704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 20 May 2025 17:59:57 +0200 Subject: [PATCH] Support `caching_sha2_password` authentication (MySQL 8+) --- src/Commands/AuthenticateCommand.php | 50 ++++++- src/Io/Parser.php | 24 ++++ tests/Commands/AuthenticateCommandTest.php | 75 +++++++++++ tests/Io/ParserTest.php | 143 ++++++++++++++++++++- 4 files changed, 288 insertions(+), 4 deletions(-) diff --git a/src/Commands/AuthenticateCommand.php b/src/Commands/AuthenticateCommand.php index 1fac31c..2fe0364 100644 --- a/src/Commands/AuthenticateCommand.php +++ b/src/Commands/AuthenticateCommand.php @@ -82,7 +82,7 @@ public function getId() */ public function authenticatePacket($scramble, $authPlugin, Buffer $buffer) { - if ($authPlugin !== null && $authPlugin !== 'mysql_native_password') { + if ($authPlugin !== null && $authPlugin !== 'mysql_native_password' && $authPlugin !== 'caching_sha2_password') { throw new \UnexpectedValueException('Unknown authentication plugin "' . addslashes($authPlugin) . '" requested by server'); } @@ -102,7 +102,7 @@ public function authenticatePacket($scramble, $authPlugin, Buffer $buffer) return pack('VVc', $clientFlags, $this->maxPacketSize, $this->charsetNumber) . "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" . $this->user . "\x00" - . $buffer->buildStringLen($this->authMysqlNativePassword($scramble)) + . $buffer->buildStringLen($authPlugin === 'caching_sha2_password' ? $this->authCachingSha2Password($scramble) : $this->authMysqlNativePassword($scramble)) . $this->dbname . "\x00" . ($authPlugin !== null ? $authPlugin . "\0" : ''); } @@ -119,4 +119,50 @@ private function authMysqlNativePassword($scramble) return \sha1($scramble . \sha1($hash1 = \sha1($this->passwd, true), true), true) ^ $hash1; } + + /** + * @param string $scramble + * @return string + * @throws \BadFunctionCallException if SHA256 hash algorithm is not available if ext-hash is missing, only possible in PHP < 7.4 + */ + private function authCachingSha2Password($scramble) + { + if ($this->passwd === '') { + return ''; + } + + if (\PHP_VERSION_ID < 70100 || !\function_exists('hash')) { + throw new \UnexpectedValueException('Requires PHP 7.1+ with ext-hash for authentication plugin "caching_sha2_password" requested by server'); + } + + \assert(\in_array('sha256', \hash_algos(), true)); + return ($hash1 = \hash('sha256', $this->passwd, true)) ^ \hash('sha256', \hash('sha256', $hash1, true) . $scramble, true); + } + + /** + * @param string $scramble + * @param string $pubkey + * @return string + * @throws \UnexpectedValueException if encryption fails (e.g. missing ext-openssl or invalid public key) + */ + public function authSha256($scramble, $pubkey) + { + if (!\function_exists('openssl_public_encrypt')) { + throw new \UnexpectedValueException('Requires ext-openssl for authentication plugin "caching_sha2_password" requested by server'); + } + + $ret = @\openssl_public_encrypt( + $this->passwd . "\x00" ^ \str_pad($scramble, \strlen($this->passwd) + 1, $scramble), + $auth, + $pubkey, + \OPENSSL_PKCS1_OAEP_PADDING + ); + + // unlikely: openssl_public_encrypt() may return false if the public key sent by the server is invalid + if ($ret === false) { + throw new \UnexpectedValueException('Failed to encrypt password with public key'); + } + + return $auth; + } } diff --git a/src/Io/Parser.php b/src/Io/Parser.php index 4c8af03..f0496f4 100644 --- a/src/Io/Parser.php +++ b/src/Io/Parser.php @@ -283,6 +283,30 @@ private function parsePacket(Buffer $packet) $this->debug('Result set next part'); ++$this->rsState; } + } elseif ($fieldCount === 0x01 && $this->phase === self::PHASE_AUTH_SENT && $this->authPlugin === 'caching_sha2_password') { + // Protocol::AuthMoreData packet + // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_auth_more_data.html + $status = $packet->readInt1(); + if ($status === 0x03 && $packet->length() === 0) { + // ignore fast auth success here, will be followed by OK packet + $this->debug('Fast auth success'); + } elseif ($status === 0x04 && $packet->length() === 0) { + // fast auth failure means we need to request the certificate to send the encrypted password + $this->debug('Fast auth failure, request certificate'); + $this->sendPacket("\x02"); + } else { + // extra auth containing certificate data + $this->debug('Extra auth certificate received, send encrypted password'); + $packet->prepend($packet->buildInt1($status)); + + try { + assert($this->currCommand instanceof AuthenticateCommand); + $this->sendPacket($this->currCommand->authSha256($this->scramble, $packet->read($packet->length()))); + } catch (\UnexpectedValueException $e) { + $this->onError($e); + $this->stream->close(); + } + } } else { // Data packet $packet->prepend($packet->buildInt1($fieldCount)); diff --git a/tests/Commands/AuthenticateCommandTest.php b/tests/Commands/AuthenticateCommandTest.php index be2fd81..f64c523 100644 --- a/tests/Commands/AuthenticateCommandTest.php +++ b/tests/Commands/AuthenticateCommandTest.php @@ -45,6 +45,15 @@ public function testAuthenticatePacketWithMysqlNativePasswordAuthPluginAndEmptyP $this->assertEquals("\x8d\xa6\x08\0\0\0\0\x01\x2d" . str_repeat("\0", 23) . "root\0" . "\0" . "test\0" . "mysql_native_password\0", $data); } + public function testAuthenticatePacketWithCachingSha2PasswordAuthPluginAndEmptyPassword() + { + $command = new AuthenticateCommand('root', '', 'test', 'utf8mb4'); + + $data = $command->authenticatePacket('scramble', 'caching_sha2_password', new Buffer()); + + $this->assertEquals("\x8d\xa6\x08\0\0\0\0\x01\x2d" . str_repeat("\0", 23) . "root\0" . "\0" . "test\0" . "caching_sha2_password\0", $data); + } + public function testAuthenticatePacketWithSecretPassword() { $command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4'); @@ -54,6 +63,19 @@ public function testAuthenticatePacketWithSecretPassword() $this->assertEquals("\x8d\xa6\0\0\0\0\0\x01\x2d" . str_repeat("\0", 23) . "root\0" . "\x14\x18\xd8\x8d\x74\x77\x2c\x27\x22\x89\xe1\xcd\xbc\x4b\x5f\x77\x08\x18\x3c\x6e\xba" . "test\0", $data); } + /** + * @requires PHP 7.1 + * @requires function hash + */ + public function testAuthenticatePacketWithCachingSha2PasswordWithSecretPasswordHashed() + { + $command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4'); + + $data = $command->authenticatePacket('scramble', 'caching_sha2_password', new Buffer()); + + $this->assertEquals("\x8d\xa6\x08\0\0\0\0\x01\x2d" . str_repeat("\0", 23) . "root\0" . "\x20\x7a\x62\x89\x95\x53\xed\xdd\xa4\x11\x2d\x28\x9a\x02\x72\x12\xbb\x4c\xdd\xfd\xd3\x08\xfe\xc3\x6a\x85\xf1\xe9\x4a\xdb\xcf\x8b\xf3" . "test\0" . "caching_sha2_password\0", $data); + } + public function testAuthenticatePacketWithUnknownAuthPluginThrows() { $command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4'); @@ -67,4 +89,57 @@ public function testAuthenticatePacketWithUnknownAuthPluginThrows() } $command->authenticatePacket('scramble', 'mysql_old_password', new Buffer()); } + + /** + * @requires function openssl_public_encrypt + */ + public function testAuthSha256WithValidPublicKeyReturnsScrambledDataThatCanBeDecryptedByReceiverWithPrivateKey() + { + $command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4'); + + $key = openssl_pkey_new(); + + $encrypted = $command->authSha256('scramble', openssl_pkey_get_details($key)['key']); + + $decrypted = ''; + $ok = openssl_private_decrypt($encrypted, $decrypted, $key, OPENSSL_PKCS1_OAEP_PADDING); + + $this->assertTrue($ok); + $this->assertEquals("secret\0", $decrypted ^ "scramble"); + } + + /** + * @requires function openssl_public_encrypt + */ + public function testAuthSha256WithPasswordLongerThanScrambleLengthReturnsScrambledDataThatCanBeDecryptedByReceiverWithPrivateKey() + { + $command = new AuthenticateCommand('root', '012345678901234567890123456789', 'test', 'utf8mb4'); + + $key = openssl_pkey_new(); + + $encrypted = $command->authSha256('scramble', openssl_pkey_get_details($key)['key']); + + $decrypted = ''; + $ok = openssl_private_decrypt($encrypted, $decrypted, $key, OPENSSL_PKCS1_OAEP_PADDING); + + $this->assertTrue($ok); + $this->assertEquals("012345678901234567890123456789\0", $decrypted ^ "scramblescramblescramblescramblescramble"); + } + + /** + * @requires function openssl_public_encrypt + */ + public function testAuthSha256WithInvalidPublicKeyThrows() + { + $command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4'); + + if (method_exists($this, 'expectException')) { + $this->expectException('UnexpectedValueException'); + $this->expectExceptionMessage('Failed to encrypt password with public key'); + } else { + // legacy PHPUnit < 5.2 + $this->setExpectedException('UnexpectedValueException', 'Failed to encrypt password with public key'); + } + $command->authSha256('scramble', 'invalid pubkey'); + } } diff --git a/tests/Io/ParserTest.php b/tests/Io/ParserTest.php index 3218534..740221b 100644 --- a/tests/Io/ParserTest.php +++ b/tests/Io/ParserTest.php @@ -43,13 +43,36 @@ public function testClosingStreamEmitsErrorForCurrentCommand() $this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $error->getCode()); } + public function testParseValidAuthPluginWillSendAuthResponse() + { + $stream = new ThroughStream(); + + $outgoing = new ThroughStream(); + $outgoing->on('data', $this->expectCallableOnceWith("\x08\0\0\x01" . "response")); + + $command = $this->getMockBuilder('React\Mysql\Commands\AuthenticateCommand')->disableOriginalConstructor()->getMock(); + $command->expects($this->once())->method('authenticatePacket')->with($this->anything(), 'caching_sha2_password')->willReturn('response'); + + $executor = new Executor(); + $executor->enqueue($command); + + $parser = new Parser(new CompositeStream($stream, $outgoing), $executor); + $parser->start(); + + $stream->write("\x49\0\0\0\x0a\x38\x2e\x34\x2e\x35\0\x5e\0\0\0\x08\x0c\x41\x44\x12\x5e\x69\x59\0\xff\xff\xff\x02\0\xff\xdf\x15\0\0\0\0\0\0\0\0\0\0\x3c\x2c\x5e\x54\x06\x04\x01\x61\x01\x20\x79\x1b\0\x63\x61\x63\x68\x69\x6e\x67\x5f\x73\x68\x61\x32\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\0"); + + $ref = new \ReflectionProperty($parser, 'authPlugin'); + $ref->setAccessible(true); + $this->assertEquals('caching_sha2_password', $ref->getValue($parser)); + } + public function testUnexpectedAuthPluginShouldEmitErrorOnAuthenticateCommandAndCloseStream() { $stream = new ThroughStream(); $stream->on('close', $this->expectCallableOnce()); $command = new AuthenticateCommand('root', '', 'test', 'utf8mb4'); - $command->on('error', $this->expectCallableOnceWith(new \UnexpectedValueException('Unknown authentication plugin "caching_sha2_password" requested by server'))); + $command->on('error', $this->expectCallableOnceWith(new \UnexpectedValueException('Unknown authentication plugin "sha256_password" requested by server'))); $executor = new Executor(); $executor->enqueue($command); @@ -57,7 +80,123 @@ public function testUnexpectedAuthPluginShouldEmitErrorOnAuthenticateCommandAndC $parser = new Parser($stream, $executor); $parser->start(); - $stream->write("\x49\0\0\0\x0a\x38\x2e\x34\x2e\x35\0\x5e\0\0\0\x08\x0c\x41\x44\x12\x5e\x69\x59\0\xff\xff\xff\x02\0\xff\xdf\x15\0\0\0\0\0\0\0\0\0\0\x3c\x2c\x5e\x54\x06\x04\x01\x61\x01\x20\x79\x1b\0\x63\x61\x63\x68\x69\x6e\x67\x5f\x73\x68\x61\x32\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\0"); + $stream->write("\x43\0\0\0\x0a\x38\x2e\x34\x2e\x35\0\x5e\0\0\0\x08\x0c\x41\x44\x12\x5e\x69\x59\0\xff\xff\xff\x02\0\xff\xdf\x15\0\0\0\0\0\0\0\0\0\0\x3c\x2c\x5e\x54\x06\x04\x01\x61\x01\x20\x79\x1b\0\x73\x68\x61\x32\x35\x36\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\0"); + } + + public function testParseAuthMoreDataWithFastAuthSuccessWillPrintDebugLogAndWaitForOkPacketWithoutSendingPacket() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableNever()); + + $outgoing = new ThroughStream(); + $outgoing->on('data', $this->expectCallableNever()); + + $executor = new Executor(); + + $parser = new Parser(new CompositeStream($stream, $outgoing), $executor); + $parser->start(); + + $ref = new \ReflectionProperty($parser, 'debug'); + $ref->setAccessible(true); + $ref->setValue($parser, true); + + $ref = new \ReflectionProperty($parser, 'phase'); + $ref->setAccessible(true); + $ref->setValue($parser, Parser::PHASE_AUTH_SENT); + + $ref = new \ReflectionProperty($parser, 'authPlugin'); + $ref->setAccessible(true); + $ref->setValue($parser, 'caching_sha2_password'); + + $this->expectOutputRegex('/Fast auth success\n$/'); + $stream->write("\x02\0\0\0" . "\x01\x03"); + } + + public function testParseAuthMoreDataWithFastAuthFailureWillSendCertificateRequest() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableNever()); + + $outgoing = new ThroughStream(); + $outgoing->on('data', $this->expectCallableOnceWith("\x01\0\0\x01" . "\x02")); + + $executor = new Executor(); + + $parser = new Parser(new CompositeStream($stream, $outgoing), $executor); + $parser->start(); + + $ref = new \ReflectionProperty($parser, 'phase'); + $ref->setAccessible(true); + $ref->setValue($parser, Parser::PHASE_AUTH_SENT); + + $ref = new \ReflectionProperty($parser, 'authPlugin'); + $ref->setAccessible(true); + $ref->setValue($parser, 'caching_sha2_password'); + + $stream->write("\x02\0\0\0" . "\x01\x04"); + } + + public function testParseAuthMoreDataWithCertificateWillSendEncryptedPassword() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableNever()); + + $outgoing = new ThroughStream(); + $outgoing->on('data', $this->expectCallableOnceWith("\x09\0\0\x01" . "encrypted")); + + $command = $this->getMockBuilder('React\Mysql\Commands\AuthenticateCommand')->disableOriginalConstructor()->getMock(); + $command->expects($this->once())->method('authSha256')->with('', '---')->willReturn('encrypted'); + + $executor = new Executor(); + + $parser = new Parser(new CompositeStream($stream, $outgoing), $executor); + $parser->start(); + + $ref = new \ReflectionProperty($parser, 'phase'); + $ref->setAccessible(true); + $ref->setValue($parser, Parser::PHASE_AUTH_SENT); + + $ref = new \ReflectionProperty($parser, 'authPlugin'); + $ref->setAccessible(true); + $ref->setValue($parser, 'caching_sha2_password'); + + $ref = new \ReflectionProperty($parser, 'currCommand'); + $ref->setAccessible(true); + $ref->setValue($parser, $command); + + $stream->write("\x04\0\0\0" . "\x01---"); + } + + public function testAuthMoreDataWithCertificateWillEmitErrorAndCloseConnectionWhenEncryptingPasswordThrows() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $outgoing = new ThroughStream(); + $outgoing->on('data', $this->expectCallableNever()); + + $command = $this->getMockBuilder('React\Mysql\Commands\AuthenticateCommand')->disableOriginalConstructor()->getMock(); + $command->expects($this->once())->method('authSha256')->with('', '---')->willThrowException(new \UnexpectedValueException('Error')); + $command->expects($this->once())->method('emit')->with('error', [new \UnexpectedValueException('Error')]); + + $executor = new Executor(); + + $parser = new Parser(new CompositeStream($stream, $outgoing), $executor); + $parser->start(); + + $ref = new \ReflectionProperty($parser, 'phase'); + $ref->setAccessible(true); + $ref->setValue($parser, Parser::PHASE_AUTH_SENT); + + $ref = new \ReflectionProperty($parser, 'authPlugin'); + $ref->setAccessible(true); + $ref->setValue($parser, 'caching_sha2_password'); + + $ref = new \ReflectionProperty($parser, 'currCommand'); + $ref->setAccessible(true); + $ref->setValue($parser, $command); + + $stream->write("\x04\0\0\0" . "\x01---"); } public function testUnexpectedErrorWithoutCurrentCommandWillBeIgnored()