diff options
| author | vitalyisaev <[email protected]> | 2023-11-14 09:58:56 +0300 |
|---|---|---|
| committer | vitalyisaev <[email protected]> | 2023-11-14 10:20:20 +0300 |
| commit | c2b2dfd9827a400a8495e172a56343462e3ceb82 (patch) | |
| tree | cd4e4f597d01bede4c82dffeb2d780d0a9046bd0 /contrib/python/scramp | |
| parent | d4ae8f119e67808cb0cf776ba6e0cf95296f2df7 (diff) | |
YQ Connector: move tests from yql to ydb (OSS)
Перенос папки с тестами на Коннектор из папки yql в папку ydb (синхронизируется с github).
Diffstat (limited to 'contrib/python/scramp')
| -rw-r--r-- | contrib/python/scramp/.dist-info/METADATA | 610 | ||||
| -rw-r--r-- | contrib/python/scramp/.dist-info/top_level.txt | 1 | ||||
| -rw-r--r-- | contrib/python/scramp/LICENSE | 18 | ||||
| -rw-r--r-- | contrib/python/scramp/README.rst | 584 | ||||
| -rw-r--r-- | contrib/python/scramp/scramp/__init__.py | 15 | ||||
| -rw-r--r-- | contrib/python/scramp/scramp/core.py | 696 | ||||
| -rw-r--r-- | contrib/python/scramp/scramp/utils.py | 34 | ||||
| -rw-r--r-- | contrib/python/scramp/ya.make | 28 |
8 files changed, 1986 insertions, 0 deletions
diff --git a/contrib/python/scramp/.dist-info/METADATA b/contrib/python/scramp/.dist-info/METADATA new file mode 100644 index 00000000000..b2254632735 --- /dev/null +++ b/contrib/python/scramp/.dist-info/METADATA @@ -0,0 +1,610 @@ +Metadata-Version: 2.1 +Name: scramp +Version: 1.4.4 +Summary: An implementation of the SCRAM protocol. +License: MIT No Attribution +Project-URL: Homepage, https://github.com/tlocke/scramp +Keywords: SCRAM,authentication,SASL +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT No Attribution License (MIT-0) +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: Implementation +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Operating System :: OS Independent +Requires-Python: >=3.7 +Description-Content-Type: text/x-rst +License-File: LICENSE +Requires-Dist: asn1crypto (>=1.5.1) +Requires-Dist: importlib-metadata (>=1.0) ; python_version < "3.8" + +====== +Scramp +====== + +A Python implementation of the `SCRAM authentication protocol +<https://en.wikipedia.org/wiki/Salted_Challenge_Response_Authentication_Mechanism>`_. +Scramp supports the following mechanisms: + +- SCRAM-SHA-1 +- SCRAM-SHA-1-PLUS +- SCRAM-SHA-256 +- SCRAM-SHA-256-PLUS +- SCRAM-SHA-512 +- SCRAM-SHA-512-PLUS +- SCRAM-SHA3-512 +- SCRAM-SHA3-512-PLUS + +.. contents:: Table of Contents + :depth: 2 + :local: + +Installation +------------ + +- Create a virtual environment: ``python3 -m venv venv`` +- Activate the virtual environment: ``source venv/bin/activate`` +- Install: ``pip install scramp`` + + +Examples +-------- + +Client and Server +````````````````` + +Here's an example using both the client and the server. It's a bit contrived as normally +you'd be using either the client or server on its own. + +>>> from scramp import ScramClient, ScramMechanism +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> MECHANISMS = ['SCRAM-SHA-256'] +>>> +>>> +>>> # Choose a mechanism for our server +>>> m = ScramMechanism() # Default is SCRAM-SHA-256 +>>> +>>> # On the server side we create the authentication information for each user +>>> # and store it in an authentication database. We'll use a dict: +>>> db = {} +>>> +>>> salt, stored_key, server_key, iteration_count = m.make_auth_info(PASSWORD) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for retrieving the authentication information +>>> # from the database given a username +>>> +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Make the SCRAM server +>>> s = m.make_server(auth_fn) +>>> +>>> # Now set up the client and carry out authentication with the server +>>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD) +>>> cfirst = c.get_client_first() +>>> +>>> s.set_client_first(cfirst) +>>> sfirst = s.get_server_first() +>>> +>>> c.set_server_first(sfirst) +>>> cfinal = c.get_client_final() +>>> +>>> s.set_client_final(cfinal) +>>> sfinal = s.get_server_final() +>>> +>>> c.set_server_final(sfinal) +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Client only +``````````` + +Here's an example using just the client. The client nonce is specified in order to give +a reproducible example, but in production you'd omit the ``c_nonce`` parameter and let +``ScramClient`` generate a client nonce: + +>>> from scramp import ScramClient +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> C_NONCE = 'rOprNGfwEbeRWgbNEkqO' +>>> MECHANISMS = ['SCRAM-SHA-256'] +>>> +>>> # Normally the c_nonce would be omitted, in which case ScramClient will +>>> # generate the nonce itself. +>>> +>>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD, c_nonce=C_NONCE) +>>> +>>> # Get the client first message and send it to the server +>>> cfirst = c.get_client_first() +>>> print(cfirst) +n,,n=user,r=rOprNGfwEbeRWgbNEkqO +>>> +>>> # Set the first message from the server +>>> c.set_server_first( +... 'r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' +... 's=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096') +>>> +>>> # Get the client final message and send it to the server +>>> cfinal = c.get_client_final() +>>> print(cfinal) +c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ= +>>> +>>> # Set the final message from the server +>>> c.set_server_final('v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=') +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Server only +``````````` + +Here's an example using just the server. The server nonce and salt is specified in order +to give a reproducible example, but in production you'd omit the ``s_nonce`` and +``salt`` parameters and let Scramp generate them: + +>>> from scramp import ScramMechanism +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' +>>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' +>>> +>>> db = {} +>>> +>>> m = ScramMechanism() +>>> +>>> salt, stored_key, server_key, iteration_count = m.make_auth_info( +... PASSWORD, salt=SALT) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for getting a password given a username +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Normally the s_nonce parameter would be omitted, in which case the +>>> # server will generate the nonce itself. +>>> +>>> s = m.make_server(auth_fn, s_nonce=S_NONCE) +>>> +>>> # Set the first message from the client +>>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') +>>> +>>> # Get the first server message, and send it to the client +>>> sfirst = s.get_server_first() +>>> print(sfirst) +r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 +>>> +>>> # Set the final message from the client +>>> s.set_client_final( +... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' +... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') +>>> +>>> # Get the final server message and send it to the client +>>> sfinal = s.get_server_final() +>>> print(sfinal) +v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Server only with passlib +```````````````````````` + +Here's an example using just the server and using the `passlib hashing library +<https://passlib.readthedocs.io/en/stable/index.html>`_. The server nonce and salt is +specified in order to give a reproducible example, but in production you'd omit the +``s_nonce`` and ``salt`` parameters and let Scramp generate them: + +>>> from scramp import ScramMechanism +>>> from passlib.hash import scram +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' +>>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' +>>> ITERATION_COUNT = 4096 +>>> +>>> db = {} +>>> hash = scram.using(salt=SALT, rounds=ITERATION_COUNT).hash(PASSWORD) +>>> +>>> salt, iteration_count, digest = scram.extract_digest_info(hash, 'sha-256') +>>> +>>> stored_key, server_key = m.make_stored_server_keys(digest) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for getting a password given a username +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Normally the s_nonce parameter would be omitted, in which case the +>>> # server will generate the nonce itself. +>>> +>>> m = ScramMechanism() +>>> s = m.make_server(auth_fn, s_nonce=S_NONCE) +>>> +>>> # Set the first message from the client +>>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') +>>> +>>> # Get the first server message, and send it to the client +>>> sfirst = s.get_server_first() +>>> print(sfirst) +r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 +>>> +>>> # Set the final message from the client +>>> s.set_client_final( +... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' +... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') +>>> +>>> # Get the final server message and send it to the client +>>> sfinal = s.get_server_final() +>>> print(sfinal) +v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Server Error +```````````` + +Here's an example of when setting a message from the client causes an error. The server +nonce and salt is specified in order to give a reproducible example, but in production +you'd omit the ``s_nonce`` and ``salt`` parameters and let Scramp generate them: + +>>> from scramp import ScramException, ScramMechanism +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' +>>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' +>>> +>>> db = {} +>>> +>>> m = ScramMechanism() +>>> +>>> salt, stored_key, server_key, iteration_count = m.make_auth_info( +... PASSWORD, salt=SALT) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for getting a password given a username +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Normally the s_nonce parameter would be omitted, in which case the +>>> # server will generate the nonce itself. +>>> +>>> s = m.make_server(auth_fn, s_nonce=S_NONCE) +>>> +>>> try: +... # Set the first message from the client +... s.set_client_first('p=tls-unique,,n=user,r=rOprNGfwEbeRWgbNEkqO') +... except ScramException as e: +... print(e) +... # Get the final server message and send it to the client +... sfinal = s.get_server_final() +... print(sfinal) +Received GS2 flag 'p' which indicates that the client requires channel binding, but the server does not: channel-binding-not-supported +e=channel-binding-not-supported + + +Standards +--------- + +`RFC 5802 <https://tools.ietf.org/html/rfc5802>`_ + Describes SCRAM. +`RFC 7677 <https://datatracker.ietf.org/doc/html/rfc7677>`_ + Registers SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. +`draft-melnikov-scram-sha-512-02 <https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha-512>`_ + Registers SCRAM-SHA-512 and SCRAM-SHA-512-PLUS. +`draft-melnikov-scram-sha3-512 <https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha3-512>`_ + Registers SCRAM-SHA3-512 and SCRAM-SHA3-512-PLUS. +`RFC 5929 <https://datatracker.ietf.org/doc/html/rfc5929>`_ + Channel Bindings for TLS. +`draft-ietf-kitten-tls-channel-bindings-for-tls13 <https://datatracker.ietf.org/doc/html/draft-ietf-kitten-tls-channel-bindings-for-tls13>`_ + Defines the ``tls-exporter`` channel binding, which is `not yet supported by Scramp + <https://github.com/tlocke/scramp/issues/9>`_. + + +API Docs +-------- + + +scramp.MECHANISMS +````````````````` + +A tuple of the supported mechanism names. + + +scramp.ScramClient +`````````````````` + +``ScramClient(mechanisms, username, password, channel_binding=None, c_nonce=None)`` + Constructor of the ``ScramClient`` class, with the following parameters: + + ``mechanisms`` + A list or tuple of mechanism names. ScramClient will choose the most secure. If + ``cbind_data`` is ``None``, the '-PLUS' variants will be filtered out first. The + chosen mechanism is available as the property ``mechanism_name``. + + ``username`` + + ``password`` + + ``channel_binding`` + Providing a value for this parameter allows channel binding to be used (ie. it lets + you use mechanisms ending in '-PLUS'). The value for ``channel_binding`` is a tuple + consisting of the channel binding name and the channel binding data. For example, if + the channel binding name is ``tls-unique``, the ``channel_binding`` parameter would + be ``('tls-unique', data)``, where ``data`` is obtained by calling + `SSLSocket.get_channel_binding() + <https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding>`_. + The convenience function ``scramp.make_channel_binding()`` can be used to create a + channel binding tuple. + + ``c_nonce`` + The client nonce. It's sometimes useful to set this when testing / debugging, but in + production this should be omitted, in which case ``ScramClient`` will generate a + client nonce. + +The ``ScramClient`` object has the following methods and properties: + +``get_client_first()`` + Get the client first message. +``set_server_first(message)`` + Set the first message from the server. +``get_client_final()`` + Get the final client message. +``set_server_final(message)`` + Set the final message from the server. +``mechanism_name`` + The mechanism chosen from the list given in the constructor. + + +scramp.ScramMechanism +````````````````````` + +``ScramMechanism(mechanism='SCRAM-SHA-256')`` + Constructor of the ``ScramMechanism`` class, with the following parameter: + + ``mechanism`` + The SCRAM mechanism to use. + +The ``ScramMechanism`` object has the following methods and properties: + +``make_auth_info(password, iteration_count=None, salt=None)`` + returns the tuple ``(salt, stored_key, server_key, iteration_count)`` which is stored + in the authentication database on the server side. It has the following parameters: + + ``password`` + The user's password as a ``str``. + + ``iteration_count`` + The rounds as an ``int``. If ``None`` then use the minimum associated with the + mechanism. + ``salt`` + It's sometimes useful to set this binary parameter when testing / debugging, but in + production this should be omitted, in which case a salt will be generated. + +``make_server(auth_fn, channel_binding=None, s_nonce=None)`` + returns a ``ScramServer`` object. It takes the following parameters: + + ``auth_fn`` + This is a function provided by the programmer that has one parameter, a username of + type ``str`` and returns returns the tuple ``(salt, stored_key, server_key, + iteration_count)``. Where ``salt``, ``stored_key`` and ``server_key`` are of a + binary type, and ``iteration_count`` is an ``int``. + + ``channel_binding`` + Providing a value for this parameter allows channel binding to be used (ie. it lets + you use mechanisms ending in ``-PLUS``). The value for ``channel_binding`` is a + tuple consisting of the channel binding name and the channel binding data. For + example, if the channel binding name is 'tls-unique', the ``channel_binding`` + parameter would be ``('tls-unique', data)``, where ``data`` is obtained by calling + `SSLSocket.get_channel_binding() + <https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding>`_. + The convenience function ``scramp.make_channel_binding()`` can be used to create a + channel binding tuple. If ``channel_binding`` is provided and the mechanism isn't a + ``-PLUS`` variant, then the server will negotiate with the client to use the + ``-PLUS`` variant if the client supports it, or otherwise to use the mechanism + without channel binding. + + ``s_nonce`` + The server nonce as a ``str``. It's sometimes useful to set this when testing / + debugging, but in production this should be omitted, in which case ``ScramServer`` + will generate a server nonce. + +``make_stored_server_keys(salted_password)`` + returns ``(stored_key, server_key)`` tuple of ``bytes`` objects given a salted + password. This is useful if you want to use a separate hashing implementation from + the one provided by Scramp. It takes the following parameter: + + ``salted_password`` + A binary object representing the hashed password. + +``iteration_count`` + The minimum iteration count recommended for this mechanism. + + +scramp.ScramServer +`````````````````` + +The ``ScramServer`` object has the following methods: + +``set_client_first(message)`` + Set the first message from the client. + +``get_server_first()`` + Get the server first message. + +``set_client_final(message)`` + Set the final client message. + +``get_server_final()`` + Get the server final message. + + +scramp.make_channel_binding() +````````````````````````````` + +``make_channel_binding(name, ssl_socket)`` + A helper function that makes a ``channel_binding`` tuple when given a channel binding + name and an SSL socket. The parameters are: + + ``name`` + A channel binding name such as 'tls-unique' or 'tls-server-end-point'. + + ``ssl_socket`` + An instance of `ssl.SSLSocket + <https://docs.python.org/3/library/ssl.html#ssl.SSLSocket>`_. + + +README.rst +---------- + +This file is written in the `reStructuredText +<https://docutils.sourceforge.io/docs/user/rst/quickref.html>`_ format. To generate an +HTML page from it, do: + +- Activate the virtual environment: ``source venv/bin/activate`` +- Install ``Sphinx``: ``pip install Sphinx`` +- Run ``rst2html.py``: ``rst2html.py README.rst README.html`` + + +Testing +------- + +- Activate the virtual environment: ``source venv/bin/activate`` +- Install ``tox``: ``pip install tox`` +- Run ``tox``: ``tox`` + + +Doing A Release Of Scramp +------------------------- + +Run ``tox`` to make sure all tests pass, then update the release notes, then do:: + + git tag -a x.y.z -m "version x.y.z" + rm -r dist + python -m build + twine upload --sign dist/* + + +Release Notes +------------- + +Version 1.4.4, 2022-11-01 +````````````````````````` + +- Tighten up parsing of messages to make sure that a ``ScramException`` is raised if a + message is malformed. + + +Version 1.4.3, 2022-10-26 +````````````````````````` + +- The client now sends a gs2-cbind-flag of 'y' if the client supports channel + binding, but thinks the server does not. + + +Version 1.4.2, 2022-10-22 +````````````````````````` + +- Switch to using the MIT-0 licence https://choosealicense.com/licenses/mit-0/ + +- When creating a ScramClient, allow non ``-PLUS`` variants, even if a + ``channel_binding`` parameter is provided. Previously this would raise and + exception. + + +Version 1.4.1, 2021-08-25 +````````````````````````` + +- When using ``make_channel_binding()`` to create a tls-server-end-point channel + binding, support certificates with hash algorithm of sha512. + + +Version 1.4.0, 2021-03-28 +````````````````````````` + +- Raise an exception if the client receives an error from the server. + + +Version 1.3.0, 2021-03-28 +````````````````````````` + +- As the specification allows, server errors are now sent to the client in the + ``server_final`` message, an exception is still thrown as before. + + +Version 1.2.2, 2021-02-13 +````````````````````````` + +- Fix bug in generating the AuthMessage. It was incorrect when channel binding + was used. So now Scramp supports channel binding. + + +Version 1.2.1, 2021-02-07 +````````````````````````` + +- Add support for channel binding. + +- Add support for SCRAM-SHA-512 and SCRAM-SHA3-512 and their channel binding + variants. + + +Version 1.2.0, 2020-05-30 +````````````````````````` + +- This is a backwardly incompatible change on the server side, the client side will + work as before. The idea of this change is to make it possible to have an + authentication database. That is, the authentication information can be stored, and + then retrieved when needed to authenticate the user. + +- In addition, it's now possible on the server side to use a third party hashing library + such as passlib as the hashing implementation. + + +Version 1.1.1, 2020-03-28 +````````````````````````` + +- Add the README and LICENCE to the distribution. + + +Version 1.1.0, 2019-02-24 +````````````````````````` + +- Add support for the SCRAM-SHA-1 mechanism. + + +Version 1.0.0, 2019-02-17 +````````````````````````` + +- Implement the server side as well as the client side. + + +Version 0.0.0, 2019-02-10 +````````````````````````` + +- Copied SCRAM implementation from `pg8000 <https://github.com/tlocke/pg8000>`_. The + idea is to make it a general SCRAM implemtation. Credit to the `Scrampy + <https://github.com/cagdass/scrampy>`_ project which I read through to help with this + project. Also credit to the `passlib <https://github.com/efficks/passlib>`_ project + from which I copied the ``saslprep`` function. diff --git a/contrib/python/scramp/.dist-info/top_level.txt b/contrib/python/scramp/.dist-info/top_level.txt new file mode 100644 index 00000000000..01f01e6b9ad --- /dev/null +++ b/contrib/python/scramp/.dist-info/top_level.txt @@ -0,0 +1 @@ +scramp diff --git a/contrib/python/scramp/LICENSE b/contrib/python/scramp/LICENSE new file mode 100644 index 00000000000..7fb92115643 --- /dev/null +++ b/contrib/python/scramp/LICENSE @@ -0,0 +1,18 @@ +MIT No Attribution + +Copyright The Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/contrib/python/scramp/README.rst b/contrib/python/scramp/README.rst new file mode 100644 index 00000000000..d9f038c0703 --- /dev/null +++ b/contrib/python/scramp/README.rst @@ -0,0 +1,584 @@ +====== +Scramp +====== + +A Python implementation of the `SCRAM authentication protocol +<https://en.wikipedia.org/wiki/Salted_Challenge_Response_Authentication_Mechanism>`_. +Scramp supports the following mechanisms: + +- SCRAM-SHA-1 +- SCRAM-SHA-1-PLUS +- SCRAM-SHA-256 +- SCRAM-SHA-256-PLUS +- SCRAM-SHA-512 +- SCRAM-SHA-512-PLUS +- SCRAM-SHA3-512 +- SCRAM-SHA3-512-PLUS + +.. contents:: Table of Contents + :depth: 2 + :local: + +Installation +------------ + +- Create a virtual environment: ``python3 -m venv venv`` +- Activate the virtual environment: ``source venv/bin/activate`` +- Install: ``pip install scramp`` + + +Examples +-------- + +Client and Server +````````````````` + +Here's an example using both the client and the server. It's a bit contrived as normally +you'd be using either the client or server on its own. + +>>> from scramp import ScramClient, ScramMechanism +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> MECHANISMS = ['SCRAM-SHA-256'] +>>> +>>> +>>> # Choose a mechanism for our server +>>> m = ScramMechanism() # Default is SCRAM-SHA-256 +>>> +>>> # On the server side we create the authentication information for each user +>>> # and store it in an authentication database. We'll use a dict: +>>> db = {} +>>> +>>> salt, stored_key, server_key, iteration_count = m.make_auth_info(PASSWORD) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for retrieving the authentication information +>>> # from the database given a username +>>> +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Make the SCRAM server +>>> s = m.make_server(auth_fn) +>>> +>>> # Now set up the client and carry out authentication with the server +>>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD) +>>> cfirst = c.get_client_first() +>>> +>>> s.set_client_first(cfirst) +>>> sfirst = s.get_server_first() +>>> +>>> c.set_server_first(sfirst) +>>> cfinal = c.get_client_final() +>>> +>>> s.set_client_final(cfinal) +>>> sfinal = s.get_server_final() +>>> +>>> c.set_server_final(sfinal) +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Client only +``````````` + +Here's an example using just the client. The client nonce is specified in order to give +a reproducible example, but in production you'd omit the ``c_nonce`` parameter and let +``ScramClient`` generate a client nonce: + +>>> from scramp import ScramClient +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> C_NONCE = 'rOprNGfwEbeRWgbNEkqO' +>>> MECHANISMS = ['SCRAM-SHA-256'] +>>> +>>> # Normally the c_nonce would be omitted, in which case ScramClient will +>>> # generate the nonce itself. +>>> +>>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD, c_nonce=C_NONCE) +>>> +>>> # Get the client first message and send it to the server +>>> cfirst = c.get_client_first() +>>> print(cfirst) +n,,n=user,r=rOprNGfwEbeRWgbNEkqO +>>> +>>> # Set the first message from the server +>>> c.set_server_first( +... 'r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' +... 's=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096') +>>> +>>> # Get the client final message and send it to the server +>>> cfinal = c.get_client_final() +>>> print(cfinal) +c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ= +>>> +>>> # Set the final message from the server +>>> c.set_server_final('v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=') +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Server only +``````````` + +Here's an example using just the server. The server nonce and salt is specified in order +to give a reproducible example, but in production you'd omit the ``s_nonce`` and +``salt`` parameters and let Scramp generate them: + +>>> from scramp import ScramMechanism +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' +>>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' +>>> +>>> db = {} +>>> +>>> m = ScramMechanism() +>>> +>>> salt, stored_key, server_key, iteration_count = m.make_auth_info( +... PASSWORD, salt=SALT) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for getting a password given a username +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Normally the s_nonce parameter would be omitted, in which case the +>>> # server will generate the nonce itself. +>>> +>>> s = m.make_server(auth_fn, s_nonce=S_NONCE) +>>> +>>> # Set the first message from the client +>>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') +>>> +>>> # Get the first server message, and send it to the client +>>> sfirst = s.get_server_first() +>>> print(sfirst) +r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 +>>> +>>> # Set the final message from the client +>>> s.set_client_final( +... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' +... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') +>>> +>>> # Get the final server message and send it to the client +>>> sfinal = s.get_server_final() +>>> print(sfinal) +v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Server only with passlib +```````````````````````` + +Here's an example using just the server and using the `passlib hashing library +<https://passlib.readthedocs.io/en/stable/index.html>`_. The server nonce and salt is +specified in order to give a reproducible example, but in production you'd omit the +``s_nonce`` and ``salt`` parameters and let Scramp generate them: + +>>> from scramp import ScramMechanism +>>> from passlib.hash import scram +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' +>>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' +>>> ITERATION_COUNT = 4096 +>>> +>>> db = {} +>>> hash = scram.using(salt=SALT, rounds=ITERATION_COUNT).hash(PASSWORD) +>>> +>>> salt, iteration_count, digest = scram.extract_digest_info(hash, 'sha-256') +>>> +>>> stored_key, server_key = m.make_stored_server_keys(digest) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for getting a password given a username +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Normally the s_nonce parameter would be omitted, in which case the +>>> # server will generate the nonce itself. +>>> +>>> m = ScramMechanism() +>>> s = m.make_server(auth_fn, s_nonce=S_NONCE) +>>> +>>> # Set the first message from the client +>>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') +>>> +>>> # Get the first server message, and send it to the client +>>> sfirst = s.get_server_first() +>>> print(sfirst) +r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 +>>> +>>> # Set the final message from the client +>>> s.set_client_final( +... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' +... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') +>>> +>>> # Get the final server message and send it to the client +>>> sfinal = s.get_server_final() +>>> print(sfinal) +v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= +>>> +>>> # If it all runs through without raising an exception, the authentication +>>> # has succeeded + + +Server Error +```````````` + +Here's an example of when setting a message from the client causes an error. The server +nonce and salt is specified in order to give a reproducible example, but in production +you'd omit the ``s_nonce`` and ``salt`` parameters and let Scramp generate them: + +>>> from scramp import ScramException, ScramMechanism +>>> +>>> USERNAME = 'user' +>>> PASSWORD = 'pencil' +>>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' +>>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' +>>> +>>> db = {} +>>> +>>> m = ScramMechanism() +>>> +>>> salt, stored_key, server_key, iteration_count = m.make_auth_info( +... PASSWORD, salt=SALT) +>>> +>>> db[USERNAME] = salt, stored_key, server_key, iteration_count +>>> +>>> # Define your own function for getting a password given a username +>>> def auth_fn(username): +... return db[username] +>>> +>>> # Normally the s_nonce parameter would be omitted, in which case the +>>> # server will generate the nonce itself. +>>> +>>> s = m.make_server(auth_fn, s_nonce=S_NONCE) +>>> +>>> try: +... # Set the first message from the client +... s.set_client_first('p=tls-unique,,n=user,r=rOprNGfwEbeRWgbNEkqO') +... except ScramException as e: +... print(e) +... # Get the final server message and send it to the client +... sfinal = s.get_server_final() +... print(sfinal) +Received GS2 flag 'p' which indicates that the client requires channel binding, but the server does not: channel-binding-not-supported +e=channel-binding-not-supported + + +Standards +--------- + +`RFC 5802 <https://tools.ietf.org/html/rfc5802>`_ + Describes SCRAM. +`RFC 7677 <https://datatracker.ietf.org/doc/html/rfc7677>`_ + Registers SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. +`draft-melnikov-scram-sha-512-02 <https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha-512>`_ + Registers SCRAM-SHA-512 and SCRAM-SHA-512-PLUS. +`draft-melnikov-scram-sha3-512 <https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha3-512>`_ + Registers SCRAM-SHA3-512 and SCRAM-SHA3-512-PLUS. +`RFC 5929 <https://datatracker.ietf.org/doc/html/rfc5929>`_ + Channel Bindings for TLS. +`draft-ietf-kitten-tls-channel-bindings-for-tls13 <https://datatracker.ietf.org/doc/html/draft-ietf-kitten-tls-channel-bindings-for-tls13>`_ + Defines the ``tls-exporter`` channel binding, which is `not yet supported by Scramp + <https://github.com/tlocke/scramp/issues/9>`_. + + +API Docs +-------- + + +scramp.MECHANISMS +````````````````` + +A tuple of the supported mechanism names. + + +scramp.ScramClient +`````````````````` + +``ScramClient(mechanisms, username, password, channel_binding=None, c_nonce=None)`` + Constructor of the ``ScramClient`` class, with the following parameters: + + ``mechanisms`` + A list or tuple of mechanism names. ScramClient will choose the most secure. If + ``cbind_data`` is ``None``, the '-PLUS' variants will be filtered out first. The + chosen mechanism is available as the property ``mechanism_name``. + + ``username`` + + ``password`` + + ``channel_binding`` + Providing a value for this parameter allows channel binding to be used (ie. it lets + you use mechanisms ending in '-PLUS'). The value for ``channel_binding`` is a tuple + consisting of the channel binding name and the channel binding data. For example, if + the channel binding name is ``tls-unique``, the ``channel_binding`` parameter would + be ``('tls-unique', data)``, where ``data`` is obtained by calling + `SSLSocket.get_channel_binding() + <https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding>`_. + The convenience function ``scramp.make_channel_binding()`` can be used to create a + channel binding tuple. + + ``c_nonce`` + The client nonce. It's sometimes useful to set this when testing / debugging, but in + production this should be omitted, in which case ``ScramClient`` will generate a + client nonce. + +The ``ScramClient`` object has the following methods and properties: + +``get_client_first()`` + Get the client first message. +``set_server_first(message)`` + Set the first message from the server. +``get_client_final()`` + Get the final client message. +``set_server_final(message)`` + Set the final message from the server. +``mechanism_name`` + The mechanism chosen from the list given in the constructor. + + +scramp.ScramMechanism +````````````````````` + +``ScramMechanism(mechanism='SCRAM-SHA-256')`` + Constructor of the ``ScramMechanism`` class, with the following parameter: + + ``mechanism`` + The SCRAM mechanism to use. + +The ``ScramMechanism`` object has the following methods and properties: + +``make_auth_info(password, iteration_count=None, salt=None)`` + returns the tuple ``(salt, stored_key, server_key, iteration_count)`` which is stored + in the authentication database on the server side. It has the following parameters: + + ``password`` + The user's password as a ``str``. + + ``iteration_count`` + The rounds as an ``int``. If ``None`` then use the minimum associated with the + mechanism. + ``salt`` + It's sometimes useful to set this binary parameter when testing / debugging, but in + production this should be omitted, in which case a salt will be generated. + +``make_server(auth_fn, channel_binding=None, s_nonce=None)`` + returns a ``ScramServer`` object. It takes the following parameters: + + ``auth_fn`` + This is a function provided by the programmer that has one parameter, a username of + type ``str`` and returns returns the tuple ``(salt, stored_key, server_key, + iteration_count)``. Where ``salt``, ``stored_key`` and ``server_key`` are of a + binary type, and ``iteration_count`` is an ``int``. + + ``channel_binding`` + Providing a value for this parameter allows channel binding to be used (ie. it lets + you use mechanisms ending in ``-PLUS``). The value for ``channel_binding`` is a + tuple consisting of the channel binding name and the channel binding data. For + example, if the channel binding name is 'tls-unique', the ``channel_binding`` + parameter would be ``('tls-unique', data)``, where ``data`` is obtained by calling + `SSLSocket.get_channel_binding() + <https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding>`_. + The convenience function ``scramp.make_channel_binding()`` can be used to create a + channel binding tuple. If ``channel_binding`` is provided and the mechanism isn't a + ``-PLUS`` variant, then the server will negotiate with the client to use the + ``-PLUS`` variant if the client supports it, or otherwise to use the mechanism + without channel binding. + + ``s_nonce`` + The server nonce as a ``str``. It's sometimes useful to set this when testing / + debugging, but in production this should be omitted, in which case ``ScramServer`` + will generate a server nonce. + +``make_stored_server_keys(salted_password)`` + returns ``(stored_key, server_key)`` tuple of ``bytes`` objects given a salted + password. This is useful if you want to use a separate hashing implementation from + the one provided by Scramp. It takes the following parameter: + + ``salted_password`` + A binary object representing the hashed password. + +``iteration_count`` + The minimum iteration count recommended for this mechanism. + + +scramp.ScramServer +`````````````````` + +The ``ScramServer`` object has the following methods: + +``set_client_first(message)`` + Set the first message from the client. + +``get_server_first()`` + Get the server first message. + +``set_client_final(message)`` + Set the final client message. + +``get_server_final()`` + Get the server final message. + + +scramp.make_channel_binding() +````````````````````````````` + +``make_channel_binding(name, ssl_socket)`` + A helper function that makes a ``channel_binding`` tuple when given a channel binding + name and an SSL socket. The parameters are: + + ``name`` + A channel binding name such as 'tls-unique' or 'tls-server-end-point'. + + ``ssl_socket`` + An instance of `ssl.SSLSocket + <https://docs.python.org/3/library/ssl.html#ssl.SSLSocket>`_. + + +README.rst +---------- + +This file is written in the `reStructuredText +<https://docutils.sourceforge.io/docs/user/rst/quickref.html>`_ format. To generate an +HTML page from it, do: + +- Activate the virtual environment: ``source venv/bin/activate`` +- Install ``Sphinx``: ``pip install Sphinx`` +- Run ``rst2html.py``: ``rst2html.py README.rst README.html`` + + +Testing +------- + +- Activate the virtual environment: ``source venv/bin/activate`` +- Install ``tox``: ``pip install tox`` +- Run ``tox``: ``tox`` + + +Doing A Release Of Scramp +------------------------- + +Run ``tox`` to make sure all tests pass, then update the release notes, then do:: + + git tag -a x.y.z -m "version x.y.z" + rm -r dist + python -m build + twine upload --sign dist/* + + +Release Notes +------------- + +Version 1.4.4, 2022-11-01 +````````````````````````` + +- Tighten up parsing of messages to make sure that a ``ScramException`` is raised if a + message is malformed. + + +Version 1.4.3, 2022-10-26 +````````````````````````` + +- The client now sends a gs2-cbind-flag of 'y' if the client supports channel + binding, but thinks the server does not. + + +Version 1.4.2, 2022-10-22 +````````````````````````` + +- Switch to using the MIT-0 licence https://choosealicense.com/licenses/mit-0/ + +- When creating a ScramClient, allow non ``-PLUS`` variants, even if a + ``channel_binding`` parameter is provided. Previously this would raise and + exception. + + +Version 1.4.1, 2021-08-25 +````````````````````````` + +- When using ``make_channel_binding()`` to create a tls-server-end-point channel + binding, support certificates with hash algorithm of sha512. + + +Version 1.4.0, 2021-03-28 +````````````````````````` + +- Raise an exception if the client receives an error from the server. + + +Version 1.3.0, 2021-03-28 +````````````````````````` + +- As the specification allows, server errors are now sent to the client in the + ``server_final`` message, an exception is still thrown as before. + + +Version 1.2.2, 2021-02-13 +````````````````````````` + +- Fix bug in generating the AuthMessage. It was incorrect when channel binding + was used. So now Scramp supports channel binding. + + +Version 1.2.1, 2021-02-07 +````````````````````````` + +- Add support for channel binding. + +- Add support for SCRAM-SHA-512 and SCRAM-SHA3-512 and their channel binding + variants. + + +Version 1.2.0, 2020-05-30 +````````````````````````` + +- This is a backwardly incompatible change on the server side, the client side will + work as before. The idea of this change is to make it possible to have an + authentication database. That is, the authentication information can be stored, and + then retrieved when needed to authenticate the user. + +- In addition, it's now possible on the server side to use a third party hashing library + such as passlib as the hashing implementation. + + +Version 1.1.1, 2020-03-28 +````````````````````````` + +- Add the README and LICENCE to the distribution. + + +Version 1.1.0, 2019-02-24 +````````````````````````` + +- Add support for the SCRAM-SHA-1 mechanism. + + +Version 1.0.0, 2019-02-17 +````````````````````````` + +- Implement the server side as well as the client side. + + +Version 0.0.0, 2019-02-10 +````````````````````````` + +- Copied SCRAM implementation from `pg8000 <https://github.com/tlocke/pg8000>`_. The + idea is to make it a general SCRAM implemtation. Credit to the `Scrampy + <https://github.com/cagdass/scrampy>`_ project which I read through to help with this + project. Also credit to the `passlib <https://github.com/efficks/passlib>`_ project + from which I copied the ``saslprep`` function. diff --git a/contrib/python/scramp/scramp/__init__.py b/contrib/python/scramp/scramp/__init__.py new file mode 100644 index 00000000000..e7a71b0eef9 --- /dev/null +++ b/contrib/python/scramp/scramp/__init__.py @@ -0,0 +1,15 @@ +from scramp.core import ( + ScramClient, + ScramException, + ScramMechanism, + make_channel_binding, +) + +__all__ = [ScramClient, ScramMechanism, ScramException, make_channel_binding] + +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + +__version__ = version("scramp") diff --git a/contrib/python/scramp/scramp/core.py b/contrib/python/scramp/scramp/core.py new file mode 100644 index 00000000000..0be76f59a56 --- /dev/null +++ b/contrib/python/scramp/scramp/core.py @@ -0,0 +1,696 @@ +import hashlib +import unicodedata +from enum import IntEnum, unique +from functools import wraps +from operator import attrgetter +from os import urandom +from stringprep import ( + in_table_a1, + in_table_b1, + in_table_c12, + in_table_c21_c22, + in_table_c3, + in_table_c4, + in_table_c5, + in_table_c6, + in_table_c7, + in_table_c8, + in_table_c9, + in_table_d1, + in_table_d2, +) +from uuid import uuid4 + +from asn1crypto.x509 import Certificate + +from scramp.utils import b64dec, b64enc, h, hi, hmac, uenc, xor + +# https://tools.ietf.org/html/rfc5802 +# https://www.rfc-editor.org/rfc/rfc7677.txt + + +@unique +class ClientStage(IntEnum): + get_client_first = 1 + set_server_first = 2 + get_client_final = 3 + set_server_final = 4 + + +@unique +class ServerStage(IntEnum): + set_client_first = 1 + get_server_first = 2 + set_client_final = 3 + get_server_final = 4 + + +def _check_stage(Stages, current_stage, next_stage): + if current_stage is None: + if next_stage != 1: + raise ScramException(f"The method {Stages(1).name} must be called first.") + elif current_stage == 4: + raise ScramException("The authentication sequence has already finished.") + elif next_stage != current_stage + 1: + raise ScramException( + f"The next method to be called is " + f"{Stages(current_stage + 1).name}, not this method." + ) + + +class ScramException(Exception): + def __init__(self, message, server_error=None): + super().__init__(message) + self.server_error = server_error + + def __str__(self): + s_str = "" if self.server_error is None else f": {self.server_error}" + return super().__str__() + s_str + + +MECHANISMS = ( + "SCRAM-SHA-1", + "SCRAM-SHA-1-PLUS", + "SCRAM-SHA-256", + "SCRAM-SHA-256-PLUS", + "SCRAM-SHA-512", + "SCRAM-SHA-512-PLUS", + "SCRAM-SHA3-512", + "SCRAM-SHA3-512-PLUS", +) + + +CHANNEL_TYPES = ( + "tls-server-end-point", + "tls-unique", + "tls-unique-for-telnet", +) + + +def _make_cb_data(name, ssl_socket): + if name == "tls-unique": + return ssl_socket.get_channel_binding(name) + + elif name == "tls-server-end-point": + cert_bin = ssl_socket.getpeercert(binary_form=True) + cert = Certificate.load(cert_bin) + + # Find the hash algorithm to use according to + # https://tools.ietf.org/html/rfc5929#section-4 + hash_algo = cert.hash_algo + if hash_algo in ("md5", "sha1"): + hash_algo = "sha256" + + try: + hash_obj = hashlib.new(hash_algo, cert_bin) + except ValueError as e: + raise ScramException( + f"Hash algorithm {hash_algo} not supported by hashlib. {e}" + ) + return hash_obj.digest() + + else: + raise ScramException(f"Channel binding name {name} not recognized.") + + +def make_channel_binding(name, ssl_socket): + return name, _make_cb_data(name, ssl_socket) + + +class ScramMechanism: + MECH_LOOKUP = { + "SCRAM-SHA-1": (hashlib.sha1, False, 4096, 0), + "SCRAM-SHA-1-PLUS": (hashlib.sha1, True, 4096, 1), + "SCRAM-SHA-256": (hashlib.sha256, False, 4096, 2), + "SCRAM-SHA-256-PLUS": (hashlib.sha256, True, 4096, 3), + "SCRAM-SHA-512": (hashlib.sha512, False, 4096, 4), + "SCRAM-SHA-512-PLUS": (hashlib.sha512, True, 4096, 5), + "SCRAM-SHA3-512": (hashlib.sha3_512, False, 10000, 6), + "SCRAM-SHA3-512-PLUS": (hashlib.sha3_512, True, 10000, 7), + } + + def __init__(self, mechanism="SCRAM-SHA-256"): + if mechanism not in MECHANISMS: + raise ScramException( + f"The mechanism name '{mechanism}' is not supported. The " + f"supported mechanisms are {MECHANISMS}." + ) + self.name = mechanism + ( + self.hf, + self.use_binding, + self.iteration_count, + self.strength, + ) = self.MECH_LOOKUP[mechanism] + + def make_auth_info(self, password, iteration_count=None, salt=None): + if iteration_count is None: + iteration_count = self.iteration_count + salt, stored_key, server_key = _make_auth_info( + self.hf, password, iteration_count, salt=salt + ) + return salt, stored_key, server_key, iteration_count + + def make_stored_server_keys(self, salted_password): + _, stored_key, server_key = _c_key_stored_key_s_key(self.hf, salted_password) + return stored_key, server_key + + def make_server(self, auth_fn, channel_binding=None, s_nonce=None): + return ScramServer( + self, auth_fn, channel_binding=channel_binding, s_nonce=s_nonce + ) + + +def _make_auth_info(hf, password, i, salt=None): + if salt is None: + salt = urandom(16) + + salted_password = _make_salted_password(hf, password, salt, i) + _, stored_key, server_key = _c_key_stored_key_s_key(hf, salted_password) + return salt, stored_key, server_key + + +def _validate_channel_binding(channel_binding): + if channel_binding is None: + return + + if not isinstance(channel_binding, tuple): + raise ScramException( + "The channel_binding parameter must either be None or a tuple." + ) + + if len(channel_binding) != 2: + raise ScramException( + "The channel_binding parameter must either be None or a tuple of two " + "elements (type, data)." + ) + + channel_type, channel_data = channel_binding + if channel_type not in CHANNEL_TYPES: + raise ScramException( + "The channel_binding parameter must either be None or a tuple with the " + "first element a str specifying one of the channel types {CHANNEL_TYPES}." + ) + + if not isinstance(channel_data, bytes): + raise ScramException( + "The channel_binding parameter must either be None or a tuple with the " + "second element a bytes object containing the bind data." + ) + + +class ScramClient: + def __init__( + self, mechanisms, username, password, channel_binding=None, c_nonce=None + ): + if not isinstance(mechanisms, (list, tuple)): + raise ScramException( + "The 'mechanisms' parameter must be a list or tuple of mechanism names." + ) + + _validate_channel_binding(channel_binding) + + ms = (ScramMechanism(m) for m in mechanisms) + mechs = [m for m in ms if not (channel_binding is None and m.use_binding)] + if len(mechs) == 0: + raise ScramException( + f"There are no suitable mechanisms in the list provided: {mechanisms}" + ) + + mech = sorted(mechs, key=attrgetter("strength"))[-1] + self.hf, self.use_binding = mech.hf, mech.use_binding + self.mechanism_name = mech.name + + self.c_nonce = _make_nonce() if c_nonce is None else c_nonce + self.username = username + self.password = password + self.channel_binding = channel_binding + self.stage = None + + def _set_stage(self, next_stage): + _check_stage(ClientStage, self.stage, next_stage) + self.stage = next_stage + + def get_client_first(self): + self._set_stage(ClientStage.get_client_first) + self.client_first_bare, client_first = _get_client_first( + self.username, self.c_nonce, self.channel_binding, self.use_binding + ) + return client_first + + def set_server_first(self, message): + self._set_stage(ClientStage.set_server_first) + self.server_first = message + self.nonce, self.salt, self.iterations = _set_server_first( + message, self.c_nonce + ) + + def get_client_final(self): + self._set_stage(ClientStage.get_client_final) + self.server_signature, cfinal = _get_client_final( + self.hf, + self.password, + self.salt, + self.iterations, + self.nonce, + self.client_first_bare, + self.server_first, + self.channel_binding, + self.use_binding, + ) + return cfinal + + def set_server_final(self, message): + self._set_stage(ClientStage.set_server_final) + _set_server_final(message, self.server_signature) + + +def set_error(f): + @wraps(f) + def wrapper(self, *args, **kwds): + try: + return f(self, *args, **kwds) + except ScramException as e: + if e.server_error is not None: + self.error = e.server_error + self.stage = ServerStage.set_client_final + raise e + + return wrapper + + +class ScramServer: + def __init__(self, mechanism, auth_fn, channel_binding=None, s_nonce=None): + + _validate_channel_binding(channel_binding) + + self.channel_binding = channel_binding + self.s_nonce = _make_nonce() if s_nonce is None else s_nonce + self.auth_fn = auth_fn + self.stage = None + self.server_signature = None + self.error = None + + self._set_mechanism(mechanism) + + def _set_mechanism(self, mechanism): + if mechanism.use_binding and self.channel_binding is None: + raise ScramException( + "The mechanism requires channel binding, and so channel_binding can't " + "be None." + ) + self.m = mechanism + + def _set_stage(self, next_stage): + _check_stage(ServerStage, self.stage, next_stage) + self.stage = next_stage + + @set_error + def set_client_first(self, client_first): + self._set_stage(ServerStage.set_client_first) + ( + self.nonce, + self.user, + self.client_first_bare, + upgrade_mechanism, + ) = _set_client_first( + client_first, self.s_nonce, self.channel_binding, self.m.use_binding + ) + + if upgrade_mechanism: + mech = ScramMechanism(f"{self.m.name}-PLUS") + self._set_mechanism(mech) + + salt, self.stored_key, self.server_key, self.i = self.auth_fn(self.user) + self.salt = b64enc(salt) + + @set_error + def get_server_first(self): + self._set_stage(ServerStage.get_server_first) + self.server_first = _get_server_first( + self.nonce, + self.salt, + self.i, + ) + return self.server_first + + @set_error + def set_client_final(self, client_final): + self._set_stage(ServerStage.set_client_final) + self.server_signature = _set_client_final( + self.m.hf, + client_final, + self.s_nonce, + self.stored_key, + self.server_key, + self.client_first_bare, + self.server_first, + self.channel_binding, + self.m.use_binding, + ) + + @set_error + def get_server_final(self): + self._set_stage(ServerStage.get_server_final) + return _get_server_final(self.server_signature, self.error) + + +def _make_nonce(): + return str(uuid4()).replace("-", "") + + +def _make_auth_message(client_first_bare, server_first, client_final_without_proof): + msg = client_first_bare, server_first, client_final_without_proof + return uenc(",".join(msg)) + + +def _make_salted_password(hf, password, salt, iterations): + return hi(hf, uenc(saslprep(password)), salt, iterations) + + +def _c_key_stored_key_s_key(hf, salted_password): + client_key = hmac(hf, salted_password, b"Client Key") + stored_key = h(hf, client_key) + server_key = hmac(hf, salted_password, b"Server Key") + + return client_key, stored_key, server_key + + +def _check_client_key(hf, stored_key, auth_msg, proof): + client_signature = hmac(hf, stored_key, auth_msg) + client_key = xor(client_signature, b64dec(proof)) + key = h(hf, client_key) + + if key != stored_key: + raise ScramException("The client keys don't match.", SERVER_ERROR_INVALID_PROOF) + + +def _make_gs2_header(channel_binding, use_binding): + if channel_binding is None: + return "n", "n,," + else: + if use_binding: + channel_type, _ = channel_binding + return "p", f"p={channel_type},," + else: + return "y", "y,," + + +def _make_cbind_input(channel_binding, use_binding): + gs2_cbind_flag, gs2_header = _make_gs2_header(channel_binding, use_binding) + gs2_header_bin = gs2_header.encode("ascii") + + if gs2_cbind_flag in ("y", "n"): + return gs2_header_bin + elif gs2_cbind_flag == "p": + _, cbind_data = channel_binding + return gs2_header_bin + cbind_data + else: + raise ScramException(f"The gs2_cbind_flag '{gs2_cbind_flag}' is not recognized") + + +def _parse_message(msg, desc, *validations): + m = {} + for p in msg.split(","): + if len(p) < 2 or p[1] != "=": + raise ScramException( + f"Malformed {desc} message. Attributes must be separated by a ',' and " + f"each attribute must start with a letter followed by a '='", + SERVER_ERROR_OTHER_ERROR, + ) + m[p[0]] = p[2:] + + m = {e[0]: e[2:] for e in msg.split(",")} + + keystr = "".join(m.keys()) + for validation in validations: + if keystr == validation: + return m + + if len(validations) == 1: + val_str = f"'{validations[0]}'" + else: + val_str = f"one of {validations}" + + raise ScramException( + f"Malformed {desc} message. Expected the attribute list to be {val_str} but " + f"found '{keystr}'", + SERVER_ERROR_OTHER_ERROR, + ) + + +def _get_client_first(username, c_nonce, channel_binding, use_binding): + try: + u = saslprep(username) + except ScramException as e: + raise ScramException(e.args[0], SERVER_ERROR_INVALID_USERNAME_ENCODING) + + bare = ",".join((f"n={u}", f"r={c_nonce}")) + _, gs2_header = _make_gs2_header(channel_binding, use_binding) + return bare, gs2_header + bare + + +def _set_client_first(client_first, s_nonce, channel_binding, use_binding): + try: + first_comma = client_first.index(",") + second_comma = client_first.index(",", first_comma + 1) + except ValueError: + raise ScramException( + "The client sent a malformed first message", + SERVER_ERROR_OTHER_ERROR, + ) + gs2_header = client_first[:second_comma].split(",") + try: + gs2_cbind_flag = gs2_header[0] + gs2_char = gs2_cbind_flag[0] + except IndexError: + raise ScramException( + "The client sent malformed gs2 data", + SERVER_ERROR_OTHER_ERROR, + ) + upgrade_mechanism = False + + if gs2_char == "y": + if channel_binding is not None: + raise ScramException( + "Recieved GS2 flag 'y' which indicates that the client doesn't think " + "the server supports channel binding, but in fact it does", + SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING, + ) + + elif gs2_char == "n": + if use_binding: + raise ScramException( + "Received GS2 flag 'n' which indicates that the client doesn't require " + "channel binding, but the server does", + SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING, + ) + + elif gs2_char == "p": + if channel_binding is None: + raise ScramException( + "Received GS2 flag 'p' which indicates that the client requires " + "channel binding, but the server does not", + SERVER_ERROR_CHANNEL_BINDING_NOT_SUPPORTED, + ) + if not use_binding: + upgrade_mechanism = True + + channel_type, _ = channel_binding + cb_name = gs2_cbind_flag.split("=")[-1] + if cb_name != channel_type: + raise ScramException( + f"Received channel binding name {cb_name} but this server supports the " + f"channel binding name {channel_type}", + SERVER_ERROR_UNSUPPORTED_CHANNEL_BINDING_TYPE, + ) + + else: + raise ScramException( + f"Received GS2 flag {gs2_char} which isn't recognized", + SERVER_ERROR_OTHER_ERROR, + ) + + client_first_bare = client_first[second_comma + 1 :] + msg = _parse_message(client_first_bare, "client first bare", "nr") + + c_nonce = msg["r"] + nonce = c_nonce + s_nonce + user = msg["n"] + + return nonce, user, client_first_bare, upgrade_mechanism + + +def _get_server_first(nonce, salt, iterations): + return ",".join((f"r={nonce}", f"s={salt}", f"i={iterations}")) + + +def _set_server_first(server_first, c_nonce): + msg = _parse_message(server_first, "server first", "rsi") + if "e" in msg: + raise ScramException(f"The server returned the error: {msg['e']}") + + nonce = msg["r"] + salt = msg["s"] + iterations = int(msg["i"]) + + if not nonce.startswith(c_nonce): + raise ScramException("Client nonce doesn't match.", SERVER_ERROR_OTHER_ERROR) + + return nonce, salt, iterations + + +def _get_client_final( + hf, + password, + salt_str, + iterations, + nonce, + client_first_bare, + server_first, + channel_binding, + use_binding, +): + salt = b64dec(salt_str) + salted_password = _make_salted_password(hf, password, salt, iterations) + client_key, stored_key, server_key = _c_key_stored_key_s_key(hf, salted_password) + + cbind_input = _make_cbind_input(channel_binding, use_binding) + client_final_without_proof = f"c={b64enc(cbind_input)},r={nonce}" + auth_msg = _make_auth_message( + client_first_bare, server_first, client_final_without_proof + ) + + client_signature = hmac(hf, stored_key, auth_msg) + client_proof = xor(client_key, client_signature) + server_signature = hmac(hf, server_key, auth_msg) + client_final = f"{client_final_without_proof},p={b64enc(client_proof)}" + return b64enc(server_signature), client_final + + +SERVER_ERROR_INVALID_ENCODING = "invalid-encoding" +SERVER_ERROR_EXTENSIONS_NOT_SUPPORTED = "extensions-not-supported" +SERVER_ERROR_INVALID_PROOF = "invalid-proof" +SERVER_ERROR_INVALID_ENCODING = "invalid-encoding" +SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH = "channel-bindings-dont-match" +SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING = "server-does-support-channel-binding" +SERVER_ERROR_SERVER_DOES_NOT_SUPPORT_CHANNEL_BINDING = ( + "server does not support channel binding" +) +SERVER_ERROR_CHANNEL_BINDING_NOT_SUPPORTED = "channel-binding-not-supported" +SERVER_ERROR_UNSUPPORTED_CHANNEL_BINDING_TYPE = "unsupported-channel-binding-type" +SERVER_ERROR_UNKNOWN_USER = "unknown-user" +SERVER_ERROR_INVALID_USERNAME_ENCODING = "invalid-username-encoding" +SERVER_ERROR_NO_RESOURCES = "no-resources" +SERVER_ERROR_OTHER_ERROR = "other-error" + + +def _set_client_final( + hf, + client_final, + s_nonce, + stored_key, + server_key, + client_first_bare, + server_first, + channel_binding, + use_binding, +): + + msg = _parse_message(client_final, "client final", "crp") + chan_binding = msg["c"] + + nonce = msg["r"] + proof = msg["p"] + if use_binding and b64dec(chan_binding) != _make_cbind_input( + channel_binding, use_binding + ): + raise ScramException( + "The channel bindings don't match.", + SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH, + ) + + if not nonce.endswith(s_nonce): + raise ScramException("Server nonce doesn't match.", SERVER_ERROR_OTHER_ERROR) + + client_final_without_proof = f"c={chan_binding},r={nonce}" + auth_msg = _make_auth_message( + client_first_bare, server_first, client_final_without_proof + ) + _check_client_key(hf, stored_key, auth_msg, proof) + + sig = hmac(hf, server_key, auth_msg) + return b64enc(sig) + + +def _get_server_final(server_signature, error): + return f"v={server_signature}" if error is None else f"e={error}" + + +def _set_server_final(message, server_signature): + msg = _parse_message(message, "server final", "v", "e") + if "e" in msg: + raise ScramException(f"The server returned the error: {msg['e']}") + + if server_signature != msg["v"]: + raise ScramException( + "The server signature doesn't match.", SERVER_ERROR_OTHER_ERROR + ) + + +def saslprep(source): + # mapping stage + # - map non-ascii spaces to U+0020 (stringprep C.1.2) + # - strip 'commonly mapped to nothing' chars (stringprep B.1) + data = "".join(" " if in_table_c12(c) else c for c in source if not in_table_b1(c)) + + # normalize to KC form + data = unicodedata.normalize("NFKC", data) + if not data: + return "" + + # check for invalid bi-directional strings. + # stringprep requires the following: + # - chars in C.8 must be prohibited. + # - if any R/AL chars in string: + # - no L chars allowed in string + # - first and last must be R/AL chars + # this checks if start/end are R/AL chars. if so, prohibited loop + # will forbid all L chars. if not, prohibited loop will forbid all + # R/AL chars instead. in both cases, prohibited loop takes care of C.8. + is_ral_char = in_table_d1 + if is_ral_char(data[0]): + if not is_ral_char(data[-1]): + raise ScramException( + "malformed bidi sequence", SERVER_ERROR_INVALID_ENCODING + ) + # forbid L chars within R/AL sequence. + is_forbidden_bidi_char = in_table_d2 + else: + # forbid R/AL chars if start not setup correctly; L chars allowed. + is_forbidden_bidi_char = is_ral_char + + # check for prohibited output + # stringprep tables A.1, B.1, C.1.2, C.2 - C.9 + for c in data: + # check for chars mapping stage should have removed + assert not in_table_b1(c), "failed to strip B.1 in mapping stage" + assert not in_table_c12(c), "failed to replace C.1.2 in mapping stage" + + # check for forbidden chars + for f, msg in ( + (in_table_a1, "unassigned code points forbidden"), + (in_table_c21_c22, "control characters forbidden"), + (in_table_c3, "private use characters forbidden"), + (in_table_c4, "non-char code points forbidden"), + (in_table_c5, "surrogate codes forbidden"), + (in_table_c6, "non-plaintext chars forbidden"), + (in_table_c7, "non-canonical chars forbidden"), + (in_table_c8, "display-modifying/deprecated chars forbidden"), + (in_table_c9, "tagged characters forbidden"), + (is_forbidden_bidi_char, "forbidden bidi character"), + ): + if f(c): + raise ScramException(msg, SERVER_ERROR_INVALID_ENCODING) + + return data diff --git a/contrib/python/scramp/scramp/utils.py b/contrib/python/scramp/scramp/utils.py new file mode 100644 index 00000000000..47e454e1ae5 --- /dev/null +++ b/contrib/python/scramp/scramp/utils.py @@ -0,0 +1,34 @@ +import hmac as hmaca +from base64 import b64decode, b64encode + + +def hmac(hf, key, msg): + return hmaca.new(key, msg=msg, digestmod=hf).digest() + + +def h(hf, msg): + return hf(msg).digest() + + +def hi(hf, password, salt, iterations): + u = ui = hmac(hf, password, salt + b"\x00\x00\x00\x01") + for i in range(iterations - 1): + ui = hmac(hf, password, ui) + u = xor(u, ui) + return u + + +def xor(bytes1, bytes2): + return bytes(a ^ b for a, b in zip(bytes1, bytes2)) + + +def b64enc(binary): + return b64encode(binary).decode("utf8") + + +def b64dec(string): + return b64decode(string) + + +def uenc(string): + return string.encode("utf-8") diff --git a/contrib/python/scramp/ya.make b/contrib/python/scramp/ya.make new file mode 100644 index 00000000000..f2b3de220b2 --- /dev/null +++ b/contrib/python/scramp/ya.make @@ -0,0 +1,28 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(1.4.4) + +LICENSE(MIT-0) + +PEERDIR( + contrib/python/asn1crypto +) + +NO_LINT() + +PY_SRCS( + TOP_LEVEL + scramp/__init__.py + scramp/core.py + scramp/utils.py +) + +RESOURCE_FILES( + PREFIX contrib/python/scramp/ + .dist-info/METADATA + .dist-info/top_level.txt +) + +END() |
