Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/Commands/AuthenticateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand All @@ -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" : '');
}
Expand All @@ -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;
}
}
24 changes: 24 additions & 0 deletions src/Io/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
75 changes: 75 additions & 0 deletions tests/Commands/AuthenticateCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
}
}
143 changes: 141 additions & 2 deletions tests/Io/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,160 @@ 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);

$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()
Expand Down