Skip to content

Commit eea8c83

Browse files
committed
Support caching_sha2_password authentication (MySQL 8+)
1 parent ee8a8ff commit eea8c83

File tree

4 files changed

+288
-4
lines changed

4 files changed

+288
-4
lines changed

src/Commands/AuthenticateCommand.php

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public function getId()
8282
*/
8383
public function authenticatePacket($scramble, $authPlugin, Buffer $buffer)
8484
{
85-
if ($authPlugin !== null && $authPlugin !== 'mysql_native_password') {
85+
if ($authPlugin !== null && $authPlugin !== 'mysql_native_password' && $authPlugin !== 'caching_sha2_password') {
8686
throw new \UnexpectedValueException('Unknown authentication plugin "' . addslashes($authPlugin) . '" requested by server');
8787
}
8888

@@ -102,7 +102,7 @@ public function authenticatePacket($scramble, $authPlugin, Buffer $buffer)
102102
return pack('VVc', $clientFlags, $this->maxPacketSize, $this->charsetNumber)
103103
. "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
104104
. $this->user . "\x00"
105-
. $buffer->buildStringLen($this->authMysqlNativePassword($scramble))
105+
. $buffer->buildStringLen($authPlugin === 'caching_sha2_password' ? $this->authCachingSha2Password($scramble) : $this->authMysqlNativePassword($scramble))
106106
. $this->dbname . "\x00"
107107
. ($authPlugin !== null ? $authPlugin . "\0" : '');
108108
}
@@ -119,4 +119,50 @@ private function authMysqlNativePassword($scramble)
119119

120120
return \sha1($scramble . \sha1($hash1 = \sha1($this->passwd, true), true), true) ^ $hash1;
121121
}
122+
123+
/**
124+
* @param string $scramble
125+
* @return string
126+
* @throws \BadFunctionCallException if SHA256 hash algorithm is not available if ext-hash is missing, only possible in PHP < 7.4
127+
*/
128+
private function authCachingSha2Password($scramble)
129+
{
130+
if ($this->passwd === '') {
131+
return '';
132+
}
133+
134+
if (\PHP_VERSION_ID < 70100 || !\function_exists('hash')) {
135+
throw new \UnexpectedValueException('Requires PHP 7.1+ with ext-hash for authentication plugin "caching_sha2_password" requested by server');
136+
}
137+
138+
\assert(\in_array('sha256', \hash_algos(), true));
139+
return ($hash1 = \hash('sha256', $this->passwd, true)) ^ \hash('sha256', \hash('sha256', $hash1, true) . $scramble, true);
140+
}
141+
142+
/**
143+
* @param string $scramble
144+
* @param string $pubkey
145+
* @return string
146+
* @throws \UnexpectedValueException if encryption fails (e.g. missing ext-openssl or invalid public key)
147+
*/
148+
public function authSha256($scramble, $pubkey)
149+
{
150+
if (!\function_exists('openssl_public_encrypt')) {
151+
throw new \UnexpectedValueException('Requires ext-openssl for authentication plugin "caching_sha2_password" requested by server');
152+
}
153+
154+
$ret = @\openssl_public_encrypt(
155+
$this->passwd . "\x00" ^ \str_pad($scramble, \strlen($this->passwd) + 1, $scramble),
156+
$auth,
157+
$pubkey,
158+
\OPENSSL_PKCS1_OAEP_PADDING
159+
);
160+
161+
// unlikely: openssl_public_encrypt() may return false if the public key sent by the server is invalid
162+
if ($ret === false) {
163+
throw new \UnexpectedValueException('Failed to encrypt password with public key');
164+
}
165+
166+
return $auth;
167+
}
122168
}

src/Io/Parser.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,30 @@ private function parsePacket(Buffer $packet)
283283
$this->debug('Result set next part');
284284
++$this->rsState;
285285
}
286+
} elseif ($fieldCount === 0x01 && $this->phase === self::PHASE_AUTH_SENT && $this->authPlugin === 'caching_sha2_password') {
287+
// Protocol::AuthMoreData packet
288+
// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_auth_more_data.html
289+
$status = $packet->readInt1();
290+
if ($status === 0x03 && $packet->length() === 0) {
291+
// ignore fast auth success here, will be followed by OK packet
292+
$this->debug('Fast auth success');
293+
} elseif ($status === 0x04 && $packet->length() === 0) {
294+
// fast auth failure means we need to request the certificate to send the encrypted password
295+
$this->debug('Fast auth failure, request certificate');
296+
$this->sendPacket("\x02");
297+
} else {
298+
// extra auth containing certificate data
299+
$this->debug('Extra auth certificate received, send encrypted password');
300+
$packet->prepend($packet->buildInt1($status));
301+
302+
try {
303+
assert($this->currCommand instanceof AuthenticateCommand);
304+
$this->sendPacket($this->currCommand->authSha256($this->scramble, $packet->read($packet->length())));
305+
} catch (\UnexpectedValueException $e) {
306+
$this->onError($e);
307+
$this->stream->close();
308+
}
309+
}
286310
} else {
287311
// Data packet
288312
$packet->prepend($packet->buildInt1($fieldCount));

tests/Commands/AuthenticateCommandTest.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ public function testAuthenticatePacketWithMysqlNativePasswordAuthPluginAndEmptyP
4545
$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);
4646
}
4747

48+
public function testAuthenticatePacketWithCachingSha2PasswordAuthPluginAndEmptyPassword()
49+
{
50+
$command = new AuthenticateCommand('root', '', 'test', 'utf8mb4');
51+
52+
$data = $command->authenticatePacket('scramble', 'caching_sha2_password', new Buffer());
53+
54+
$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);
55+
}
56+
4857
public function testAuthenticatePacketWithSecretPassword()
4958
{
5059
$command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4');
@@ -54,6 +63,19 @@ public function testAuthenticatePacketWithSecretPassword()
5463
$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);
5564
}
5665

66+
/**
67+
* @requires PHP 7.1
68+
* @requires function hash
69+
*/
70+
public function testAuthenticatePacketWithCachingSha2PasswordWithSecretPasswordHashed()
71+
{
72+
$command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4');
73+
74+
$data = $command->authenticatePacket('scramble', 'caching_sha2_password', new Buffer());
75+
76+
$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);
77+
}
78+
5779
public function testAuthenticatePacketWithUnknownAuthPluginThrows()
5880
{
5981
$command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4');
@@ -67,4 +89,57 @@ public function testAuthenticatePacketWithUnknownAuthPluginThrows()
6789
}
6890
$command->authenticatePacket('scramble', 'mysql_old_password', new Buffer());
6991
}
92+
93+
/**
94+
* @requires function openssl_public_encrypt
95+
*/
96+
public function testAuthSha256WithValidPublicKeyReturnsScrambledDataThatCanBeDecryptedByReceiverWithPrivateKey()
97+
{
98+
$command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4');
99+
100+
$key = openssl_pkey_new();
101+
102+
$encrypted = $command->authSha256('scramble', openssl_pkey_get_details($key)['key']);
103+
104+
$decrypted = '';
105+
$ok = openssl_private_decrypt($encrypted, $decrypted, $key, OPENSSL_PKCS1_OAEP_PADDING);
106+
107+
$this->assertTrue($ok);
108+
$this->assertEquals("secret\0", $decrypted ^ "scramble");
109+
}
110+
111+
/**
112+
* @requires function openssl_public_encrypt
113+
*/
114+
public function testAuthSha256WithPasswordLongerThanScrambleLengthReturnsScrambledDataThatCanBeDecryptedByReceiverWithPrivateKey()
115+
{
116+
$command = new AuthenticateCommand('root', '012345678901234567890123456789', 'test', 'utf8mb4');
117+
118+
$key = openssl_pkey_new();
119+
120+
$encrypted = $command->authSha256('scramble', openssl_pkey_get_details($key)['key']);
121+
122+
$decrypted = '';
123+
$ok = openssl_private_decrypt($encrypted, $decrypted, $key, OPENSSL_PKCS1_OAEP_PADDING);
124+
125+
$this->assertTrue($ok);
126+
$this->assertEquals("012345678901234567890123456789\0", $decrypted ^ "scramblescramblescramblescramblescramble");
127+
}
128+
129+
/**
130+
* @requires function openssl_public_encrypt
131+
*/
132+
public function testAuthSha256WithInvalidPublicKeyThrows()
133+
{
134+
$command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4');
135+
136+
if (method_exists($this, 'expectException')) {
137+
$this->expectException('UnexpectedValueException');
138+
$this->expectExceptionMessage('Failed to encrypt password with public key');
139+
} else {
140+
// legacy PHPUnit < 5.2
141+
$this->setExpectedException('UnexpectedValueException', 'Failed to encrypt password with public key');
142+
}
143+
$command->authSha256('scramble', 'invalid pubkey');
144+
}
70145
}

tests/Io/ParserTest.php

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,160 @@ public function testClosingStreamEmitsErrorForCurrentCommand()
4343
$this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $error->getCode());
4444
}
4545

46+
public function testParseValidAuthPluginWillSendAuthResponse()
47+
{
48+
$stream = new ThroughStream();
49+
50+
$outgoing = new ThroughStream();
51+
$outgoing->on('data', $this->expectCallableOnceWith("\x08\0\0\x01" . "response"));
52+
53+
$command = $this->getMockBuilder(AuthenticateCommand::class)->disableOriginalConstructor()->getMock();
54+
$command->expects($this->once())->method('authenticatePacket')->with($this->anything(), 'caching_sha2_password')->willReturn('response');
55+
56+
$executor = new Executor();
57+
$executor->enqueue($command);
58+
59+
$parser = new Parser(new CompositeStream($stream, $outgoing), $executor);
60+
$parser->start();
61+
62+
$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");
63+
64+
$ref = new \ReflectionProperty($parser, 'authPlugin');
65+
$ref->setAccessible(true);
66+
$this->assertEquals('caching_sha2_password', $ref->getValue($parser));
67+
}
68+
4669
public function testUnexpectedAuthPluginShouldEmitErrorOnAuthenticateCommandAndCloseStream()
4770
{
4871
$stream = new ThroughStream();
4972
$stream->on('close', $this->expectCallableOnce());
5073

5174
$command = new AuthenticateCommand('root', '', 'test', 'utf8mb4');
52-
$command->on('error', $this->expectCallableOnceWith(new \UnexpectedValueException('Unknown authentication plugin "caching_sha2_password" requested by server')));
75+
$command->on('error', $this->expectCallableOnceWith(new \UnexpectedValueException('Unknown authentication plugin "sha256_password" requested by server')));
5376

5477
$executor = new Executor();
5578
$executor->enqueue($command);
5679

5780
$parser = new Parser($stream, $executor);
5881
$parser->start();
5982

60-
$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");
83+
$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");
84+
}
85+
86+
public function testParseAuthMoreDataWithFastAuthSuccessWillPrintDebugLogAndWaitForOkPacketWithoutSendingPacket()
87+
{
88+
$stream = new ThroughStream();
89+
$stream->on('close', $this->expectCallableNever());
90+
91+
$outgoing = new ThroughStream();
92+
$outgoing->on('data', $this->expectCallableNever());
93+
94+
$executor = new Executor();
95+
96+
$parser = new Parser(new CompositeStream($stream, $outgoing), $executor);
97+
$parser->start();
98+
99+
$ref = new \ReflectionProperty($parser, 'debug');
100+
$ref->setAccessible(true);
101+
$ref->setValue($parser, true);
102+
103+
$ref = new \ReflectionProperty($parser, 'phase');
104+
$ref->setAccessible(true);
105+
$ref->setValue($parser, Parser::PHASE_AUTH_SENT);
106+
107+
$ref = new \ReflectionProperty($parser, 'authPlugin');
108+
$ref->setAccessible(true);
109+
$ref->setValue($parser, 'caching_sha2_password');
110+
111+
$this->expectOutputRegex('/Fast auth success\n$/');
112+
$stream->write("\x02\0\0\0" . "\x01\x03");
113+
}
114+
115+
public function testParseAuthMoreDataWithFastAuthFailureWillSendCertificateRequest()
116+
{
117+
$stream = new ThroughStream();
118+
$stream->on('close', $this->expectCallableNever());
119+
120+
$outgoing = new ThroughStream();
121+
$outgoing->on('data', $this->expectCallableOnceWith("\x01\0\0\x01" . "\x02"));
122+
123+
$executor = new Executor();
124+
125+
$parser = new Parser(new CompositeStream($stream, $outgoing), $executor);
126+
$parser->start();
127+
128+
$ref = new \ReflectionProperty($parser, 'phase');
129+
$ref->setAccessible(true);
130+
$ref->setValue($parser, Parser::PHASE_AUTH_SENT);
131+
132+
$ref = new \ReflectionProperty($parser, 'authPlugin');
133+
$ref->setAccessible(true);
134+
$ref->setValue($parser, 'caching_sha2_password');
135+
136+
$stream->write("\x02\0\0\0" . "\x01\x04");
137+
}
138+
139+
public function testParseAuthMoreDataWithCertificateWillSendEncryptedPassword()
140+
{
141+
$stream = new ThroughStream();
142+
$stream->on('close', $this->expectCallableNever());
143+
144+
$outgoing = new ThroughStream();
145+
$outgoing->on('data', $this->expectCallableOnceWith("\x09\0\0\x01" . "encrypted"));
146+
147+
$command = $this->getMockBuilder(AuthenticateCommand::class)->disableOriginalConstructor()->getMock();
148+
$command->expects($this->once())->method('authSha256')->with('', '---')->willReturn('encrypted');
149+
150+
$executor = new Executor();
151+
152+
$parser = new Parser(new CompositeStream($stream, $outgoing), $executor);
153+
$parser->start();
154+
155+
$ref = new \ReflectionProperty($parser, 'phase');
156+
$ref->setAccessible(true);
157+
$ref->setValue($parser, Parser::PHASE_AUTH_SENT);
158+
159+
$ref = new \ReflectionProperty($parser, 'authPlugin');
160+
$ref->setAccessible(true);
161+
$ref->setValue($parser, 'caching_sha2_password');
162+
163+
$ref = new \ReflectionProperty($parser, 'currCommand');
164+
$ref->setAccessible(true);
165+
$ref->setValue($parser, $command);
166+
167+
$stream->write("\x04\0\0\0" . "\x01---");
168+
}
169+
170+
public function testAuthMoreDataWithCertificateWillEmitErrorAndCloseConnectionWhenEncryptingPasswordThrows()
171+
{
172+
$stream = new ThroughStream();
173+
$stream->on('close', $this->expectCallableOnce());
174+
175+
$outgoing = new ThroughStream();
176+
$outgoing->on('data', $this->expectCallableNever());
177+
178+
$command = $this->getMockBuilder(AuthenticateCommand::class)->disableOriginalConstructor()->getMock();
179+
$command->expects($this->once())->method('authSha256')->with('', '---')->willThrowException(new \UnexpectedValueException('Error'));
180+
$command->expects($this->once())->method('emit')->with('error', [new \UnexpectedValueException('Error')]);
181+
182+
$executor = new Executor();
183+
184+
$parser = new Parser(new CompositeStream($stream, $outgoing), $executor);
185+
$parser->start();
186+
187+
$ref = new \ReflectionProperty($parser, 'phase');
188+
$ref->setAccessible(true);
189+
$ref->setValue($parser, Parser::PHASE_AUTH_SENT);
190+
191+
$ref = new \ReflectionProperty($parser, 'authPlugin');
192+
$ref->setAccessible(true);
193+
$ref->setValue($parser, 'caching_sha2_password');
194+
195+
$ref = new \ReflectionProperty($parser, 'currCommand');
196+
$ref->setAccessible(true);
197+
$ref->setValue($parser, $command);
198+
199+
$stream->write("\x04\0\0\0" . "\x01---");
61200
}
62201

63202
public function testUnexpectedErrorWithoutCurrentCommandWillBeIgnored()

0 commit comments

Comments
 (0)