Skip to content

Commit 497a21e

Browse files
committed
WL11668: Add SHA256_MEMORY authentication mechanism
This worklog adds support for SHA256_MEMORY authentication mechanism. A new test was added for regression.
1 parent f18d043 commit 497a21e

File tree

5 files changed

+148
-64
lines changed

5 files changed

+148
-64
lines changed

lib/mysqlx/authentication.py

Lines changed: 69 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2016, 2017, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2016, 2018, Oracle and/or its affiliates. All rights reserved.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -34,47 +34,66 @@
3434
from .compat import PY3, UNICODE_TYPES, hexlify
3535

3636

37-
class MySQL41AuthPlugin(object):
38-
"""Class implementing the MySQL Native Password authentication plugin."""
39-
def __init__(self, username, password):
37+
def xor_string(hash1, hash2, hash_size):
38+
"""Encrypt/Decrypt function used for password encryption in
39+
authentication, using a simple XOR.
40+
41+
Args:
42+
hash1 (str): The first hash.
43+
hash2 (str): The second hash.
44+
45+
Returns:
46+
str: A string with the xor applied.
47+
"""
48+
if PY3:
49+
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2)]
50+
else:
51+
xored = [ord(h1) ^ ord(h2) for (h1, h2) in zip(hash1, hash2)]
52+
return struct.pack("{0}B".format(hash_size), *xored)
53+
54+
55+
class BaseAuthPlugin(object):
56+
"""Base class for implementing the authentication plugins."""
57+
def __init__(self, username=None, password=None):
4058
self._username = username
41-
self._password = password.encode("utf-8") \
42-
if isinstance(password, UNICODE_TYPES) else password
59+
self._password = password
4360

4461
def name(self):
4562
"""Returns the plugin name.
4663
4764
Returns:
4865
str: The plugin name.
4966
"""
50-
return "MySQL 4.1 Authentication Plugin"
67+
raise NotImplementedError
5168

5269
def auth_name(self):
5370
"""Returns the authentication name.
5471
5572
Returns:
5673
str: The authentication name.
5774
"""
58-
return "MYSQL41"
75+
raise NotImplementedError
5976

60-
def xor_string(self, hash1, hash2):
61-
"""Encrypt/Decrypt function used for password encryption in
62-
authentication, using a simple XOR.
6377

64-
Args:
65-
hash1 (str): The first hash.
66-
hash2 (str): The second hash.
78+
class MySQL41AuthPlugin(BaseAuthPlugin):
79+
"""Class implementing the MySQL Native Password authentication plugin."""
80+
def name(self):
81+
"""Returns the plugin name.
82+
83+
Returns:
84+
str: The plugin name.
85+
"""
86+
return "MySQL 4.1 Authentication Plugin"
87+
88+
def auth_name(self):
89+
"""Returns the authentication name.
6790
6891
Returns:
69-
str: A string with the xor applied.
92+
str: The authentication name.
7093
"""
71-
if PY3:
72-
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2)]
73-
else:
74-
xored = [ord(h1) ^ ord(h2) for (h1, h2) in zip(hash1, hash2)]
75-
return struct.pack("20B", *xored)
94+
return "MYSQL41"
7695

77-
def build_authentication_response(self, data):
96+
def auth_data(self, data):
7897
"""Hashing for MySQL 4.1 authentication.
7998
8099
Args:
@@ -84,22 +103,17 @@ def build_authentication_response(self, data):
84103
str: The authentication response.
85104
"""
86105
if self._password:
87-
hash1 = hashlib.sha1(self._password).digest()
106+
password = self._password.encode("utf-8") \
107+
if isinstance(self._password, UNICODE_TYPES) else self._password
108+
hash1 = hashlib.sha1(password).digest()
88109
hash2 = hashlib.sha1(hash1).digest()
89-
auth_response = self.xor_string(
90-
hash1, hashlib.sha1(data + hash2).digest())
91-
return "{0}\0{1}\0*{2}\0".format("", self._username,
92-
hexlify(auth_response))
110+
xored = xor_string(hash1, hashlib.sha1(data + hash2).digest(), 20)
111+
return "{0}\0{1}\0*{2}\0".format("", self._username, hexlify(xored))
93112
return "{0}\0{1}\0".format("", self._username)
94113

95114

96-
class PlainAuthPlugin(object):
115+
class PlainAuthPlugin(BaseAuthPlugin):
97116
"""Class implementing the MySQL Plain authentication plugin."""
98-
def __init__(self, username, password):
99-
self._username = username
100-
self._password = password.encode("utf-8") \
101-
if isinstance(password, UNICODE_TYPES) and not PY3 else password
102-
103117
def name(self):
104118
"""Returns the plugin name.
105119
@@ -122,31 +136,45 @@ def auth_data(self):
122136
Returns:
123137
str: The authentication data.
124138
"""
125-
return "\0{0}\0{1}".format(self._username, self._password)
139+
password = self._password.encode("utf-8") \
140+
if isinstance(self._password, UNICODE_TYPES) and not PY3 \
141+
else self._password
142+
return "\0{0}\0{1}".format(self._username, password)
126143

127144

128-
class ExternalAuthPlugin(object):
129-
"""Class implementing the External authentication plugin."""
145+
class Sha256MemoryAuthPlugin(BaseAuthPlugin):
146+
"""Class implementing the SHA256_MEMORY authentication plugin."""
130147
def name(self):
131148
"""Returns the plugin name.
132149
133150
Returns:
134151
str: The plugin name.
135152
"""
136-
return "External Authentication Plugin"
153+
return "SHA256_MEMORY Authentication Plugin"
137154

138155
def auth_name(self):
139156
"""Returns the authentication name.
140157
141158
Returns:
142159
str: The authentication name.
143160
"""
144-
return "EXTERNAL"
161+
return "SHA256_MEMORY"
145162

146-
def initial_response(self):
147-
"""Returns the initial response.
163+
def auth_data(self, data):
164+
"""Hashing for SHA256_MEMORY authentication.
165+
166+
The scramble is of the form:
167+
SHA256(SHA256(SHA256(PASSWORD)),NONCE) XOR SHA256(PASSWORD)
168+
169+
Args:
170+
data (str): The authentication data.
148171
149172
Returns:
150-
str: The initial response.
173+
str: The authentication response.
151174
"""
152-
return ""
175+
password = self._password.encode("utf-8") \
176+
if isinstance(self._password, UNICODE_TYPES) else self._password
177+
hash1 = hashlib.sha256(password).digest()
178+
hash2 = hashlib.sha256(hashlib.sha256(hash1).digest() + data).digest()
179+
xored = xor_string(hash2, hash1, 32)
180+
return "\0{0}\0{1}".format(self._username, hexlify(xored))

lib/mysqlx/connection.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from functools import wraps
4444

4545
from .authentication import (MySQL41AuthPlugin, PlainAuthPlugin,
46-
ExternalAuthPlugin)
46+
Sha256MemoryAuthPlugin)
4747
from .errors import InterfaceError, OperationalError, ProgrammingError
4848
from .compat import PY3, STRING_TYPES, UNICODE_TYPES
4949
from .crud import Schema
@@ -85,7 +85,7 @@ def connect(self, params):
8585
self._is_socket = True
8686
self._socket.connect(params)
8787
except AttributeError:
88-
raise InterfaceError("Unix socket unsupported.")
88+
raise InterfaceError("Unix socket unsupported")
8989

9090
def read(self, count):
9191
"""Receive data from the socket.
@@ -142,7 +142,7 @@ def set_ssl(self, ssl_mode, ssl_ca, ssl_crl, ssl_cert, ssl_key):
142142
"""
143143
if not SSL_AVAILABLE:
144144
self.close()
145-
raise RuntimeError("Python installation has no SSL support.")
145+
raise RuntimeError("Python installation has no SSL support")
146146

147147
context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
148148
context.load_default_certs()
@@ -228,7 +228,7 @@ def wrapper(self, *args, **kwargs):
228228
return func(self, *args, **kwargs)
229229
except (socket.error, RuntimeError):
230230
self.disconnect()
231-
raise InterfaceError("Cannot connect to host.")
231+
raise InterfaceError("Cannot connect to host")
232232
return wrapper
233233

234234

@@ -355,7 +355,7 @@ def connect(self):
355355

356356
if len(self._routers) <= 1:
357357
raise InterfaceError("Cannot connect to host: {0}".format(error))
358-
raise InterfaceError("Failed to connect to any of the routers.", 4001)
358+
raise InterfaceError("Failed to connect to any of the routers", 4001)
359359

360360
def _handle_capabilities(self):
361361
"""Handle capabilities.
@@ -377,7 +377,7 @@ def _handle_capabilities(self):
377377
if not (get_item_or_attr(data[0], "name").lower() == "tls"
378378
if data else False):
379379
self.close_connection()
380-
raise OperationalError("SSL not enabled at server.")
380+
raise OperationalError("SSL not enabled at server")
381381

382382
is_ol7 = False
383383
if platform.system() == "Linux":
@@ -392,7 +392,7 @@ def _handle_capabilities(self):
392392
if sys.version_info < (2, 7, 9) and not is_ol7:
393393
self.close_connection()
394394
raise RuntimeError("The support for SSL is not available for "
395-
"this Python version.")
395+
"this Python version")
396396

397397
self.protocol.set_capabilities(tls=True)
398398
self.stream.set_ssl(self.settings.get("ssl-mode", SSLMode.REQUIRED),
@@ -404,34 +404,56 @@ def _handle_capabilities(self):
404404
def _authenticate(self):
405405
"""Authenticate with the MySQL server."""
406406
auth = self.settings.get("auth")
407-
if (not auth and self.stream.is_secure()) or auth == Auth.PLAIN:
407+
if auth:
408+
if auth == Auth.PLAIN:
409+
self._authenticate_plain()
410+
elif auth == Auth.SHA256_MEMORY:
411+
self._authenticate_sha256_memory()
412+
elif auth == Auth.MYSQL41:
413+
self._authenticate_mysql41()
414+
elif self.stream.is_secure():
415+
# Use PLAIN if no auth provided and connection is secure
408416
self._authenticate_plain()
409-
elif auth == Auth.EXTERNAL:
410-
self._authenticate_external()
411417
else:
412-
self._authenticate_mysql41()
418+
# Use MYSQL41 if connection is not secure
419+
try:
420+
self._authenticate_mysql41()
421+
except InterfaceError:
422+
pass
423+
else:
424+
return
425+
# Try SHA256_MEMORY if MYSQL41 fails
426+
try:
427+
self._authenticate_sha256_memory()
428+
except InterfaceError:
429+
raise InterfaceError("Authentication failed using MYSQL41 and "
430+
"SHA256_MEMORY, check username and "
431+
"password or try a secure connection")
413432

414433
def _authenticate_mysql41(self):
415434
"""Authenticate with the MySQL server using `MySQL41AuthPlugin`."""
416435
plugin = MySQL41AuthPlugin(self._user, self._password)
417436
self.protocol.send_auth_start(plugin.auth_name())
418437
extra_data = self.protocol.read_auth_continue()
419-
self.protocol.send_auth_continue(
420-
plugin.build_authentication_response(extra_data))
438+
self.protocol.send_auth_continue(plugin.auth_data(extra_data))
421439
self.protocol.read_auth_ok()
422440

423441
def _authenticate_plain(self):
424442
"""Authenticate with the MySQL server using `PlainAuthPlugin`."""
443+
if not self.stream.is_secure():
444+
raise InterfaceError("PLAIN authentication is not allowed via "
445+
"unencrypted connection")
425446
plugin = PlainAuthPlugin(self._user, self._password)
426447
self.protocol.send_auth_start(plugin.auth_name(),
427448
auth_data=plugin.auth_data())
428449
self.protocol.read_auth_ok()
429450

430-
def _authenticate_external(self):
431-
"""Authenticate with the MySQL server using `ExternalAuthPlugin`."""
432-
plugin = ExternalAuthPlugin()
433-
self.protocol.send_auth_start(
434-
plugin.auth_name(), initial_response=plugin.initial_response())
451+
def _authenticate_sha256_memory(self):
452+
"""Authenticate with the MySQL server using `Sha256MemoryAuthPlugin`."""
453+
plugin = Sha256MemoryAuthPlugin(self._user, self._password)
454+
self.protocol.send_auth_start(plugin.auth_name())
455+
extra_data = self.protocol.read_auth_continue()
456+
self.protocol.send_auth_continue(plugin.auth_data(extra_data))
435457
self.protocol.read_auth_ok()
436458

437459
@catch_network_exception

lib/mysqlx/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ def create_enum(name, fields, values=None):
5353
("REQUIRED", "DISABLED", "VERIFY_CA", "VERIFY_IDENTITY"),
5454
("required", "disabled", "verify_ca", "verify_identity"))
5555
Auth = create_enum("Auth",
56-
("PLAIN", "EXTERNAL", "MYSQL41"),
57-
("plain", "external", "mysql41"))
56+
("PLAIN", "MYSQL41", "SHA256_MEMORY"),
57+
("plain", "mysql41", "sha256_memory"))
5858
LockContention = create_enum("LockContention",
5959
("DEFAULT", "NOWAIT", "SKIP_LOCKED"), (0, 1, 2))
6060

lib/mysqlx/protocol.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,14 +325,14 @@ def read_auth_continue(self):
325325
"authentication handshake")
326326
return msg["auth_data"]
327327

328-
def send_auth_continue(self, data):
328+
def send_auth_continue(self, auth_data):
329329
"""Send authenticate continue.
330330
331331
Args:
332-
data (str): Authentication data.
332+
auth_data (str): Authentication data.
333333
"""
334334
msg = Message("Mysqlx.Session.AuthenticateContinue",
335-
auth_data=data)
335+
auth_data=auth_data)
336336
self._writer.write_message(mysqlxpb_enum(
337337
"Mysqlx.ClientMessages.Type.SESS_AUTHENTICATE_CONTINUE"), msg)
338338

tests/test_mysqlx_connection.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,40 @@ def test_auth(self):
302302
sess.sql("DROP USER 'sha256'@'%'").execute()
303303
sess.close()
304304

305+
@unittest.skipIf(tests.MYSQL_VERSION < (8, 0, 5),
306+
"SHA256_MEMORY authentation mechanism not available")
307+
def test_auth_sha265_memory(self):
308+
sess = mysqlx.get_session(self.connect_kwargs)
309+
sess.sql("CREATE USER 'caching'@'%' IDENTIFIED WITH "
310+
"caching_sha2_password BY 'caching'").execute()
311+
config = {
312+
"user": "caching",
313+
"password": "caching",
314+
"host": self.connect_kwargs["host"],
315+
"port": self.connect_kwargs["port"]
316+
}
317+
318+
# Session creation is not possible with SSL disabled
319+
config["ssl-mode"] = mysqlx.SSLMode.DISABLED
320+
self.assertRaises(InterfaceError, mysqlx.get_session, config)
321+
config["auth"] = mysqlx.Auth.SHA256_MEMORY
322+
self.assertRaises(InterfaceError, mysqlx.get_session, config)
323+
324+
# Session creation is possible with SSL enabled
325+
config["ssl-mode"] = mysqlx.SSLMode.REQUIRED
326+
config["auth"] = mysqlx.Auth.PLAIN
327+
mysqlx.get_session(config)
328+
329+
# Disable SSL
330+
config["ssl-mode"] = mysqlx.SSLMode.DISABLED
331+
332+
# Password is in cache will, session creation is possible
333+
config["auth"] = mysqlx.Auth.SHA256_MEMORY
334+
mysqlx.get_session(config)
335+
336+
sess.sql("DROP USER 'caching'@'%'").execute()
337+
sess.close()
338+
305339
@unittest.skipIf(tests.MYSQL_VERSION < (5, 7, 15), "--mysqlx-socket option tests not available for this MySQL version")
306340
def test_mysqlx_socket(self):
307341
# Connect with unix socket

0 commit comments

Comments
 (0)