From fb19b652bf915e20ae6e2eb0970045df7927c0b0 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 2 Feb 2026 05:17:01 -0400 Subject: [PATCH] Remove pure-sasl dependency by implementing internal SASL client This change eliminates the external pure-sasl dependency which has been unmaintained since 2019 (addresses #666). The implementation provides: - Internal SASL client in cassandra/sasl.py based on pure-sasl (MIT licensed) - Full PLAIN mechanism support for username/password authentication - Full GSSAPI mechanism support for Kerberos authentication with QOP negotiation - Platform-aware kerberos library selection (kerberos/winkerberos) The internal implementation maintains API compatibility with existing code while removing the risk of depending on an unmaintained external library. --- cassandra/auth.py | 26 +- cassandra/sasl.py | 301 ++++++++++++++++++ docs/security.rst | 3 +- pyproject.toml | 1 - .../standard/test_authentication.py | 2 - tests/unit/test_auth.py | 87 ++++- 6 files changed, 393 insertions(+), 27 deletions(-) create mode 100644 cassandra/sasl.py diff --git a/cassandra/auth.py b/cassandra/auth.py index f41ba9f73d..4c61283266 100644 --- a/cassandra/auth.py +++ b/cassandra/auth.py @@ -21,16 +21,7 @@ except ImportError: _have_kerberos = False -try: - from puresasl.client import SASLClient - _have_puresasl = True -except ImportError: - _have_puresasl = False - -try: - from puresasl.client import SASLClient -except ImportError: - SASLClient = None +from cassandra.sasl import SASLClient log = logging.getLogger(__name__) @@ -184,8 +175,6 @@ class SaslAuthProvider(AuthProvider): """ def __init__(self, **sasl_kwargs): - if SASLClient is None: - raise ImportError('The puresasl library has not been installed') if 'host' in sasl_kwargs: raise ValueError("kwargs should not contain 'host' since it is passed dynamically to new_authenticator") self.sasl_kwargs = sasl_kwargs @@ -196,15 +185,12 @@ def new_authenticator(self, host): class SaslAuthenticator(Authenticator): """ - A pass-through :class:`~.Authenticator` using the third party package - 'pure-sasl' for authentication + A :class:`~.Authenticator` using SASL for authentication .. versionadded:: 2.1.4 """ def __init__(self, host, service, mechanism='GSSAPI', **sasl_kwargs): - if SASLClient is None: - raise ImportError('The puresasl library has not been installed') self.sasl = SASLClient(host, service, mechanism, **sasl_kwargs) def initial_response(self): @@ -225,15 +211,13 @@ class DSEGSSAPIAuthProvider(AuthProvider): def __init__(self, service='dse', qops=('auth',), resolve_host_name=True, **properties): """ :param service: name of the service - :param qops: iterable of "Quality of Protection" allowed; see ``puresasl.QOP`` + :param qops: iterable of "Quality of Protection" allowed; see ``cassandra.sasl.QOP`` :param resolve_host_name: boolean flag indicating whether the authenticator should reverse-lookup an FQDN when creating a new authenticator. Default is ``True``, which will resolve, or return the numeric address if there is no PTR record. Setting ``False`` creates the authenticator with the numeric address known by Cassandra - :param properties: additional keyword properties to pass for the ``puresasl.mechanisms.GSSAPIMechanism`` class. - Presently, 'principal' (user) is the only one referenced in the ``pure-sasl`` implementation + :param properties: additional keyword properties to pass for the GSSAPI mechanism. + Presently, 'principal' (user) is the only one referenced in the implementation """ - if not _have_puresasl: - raise ImportError('The puresasl library has not been installed') if not _have_kerberos: raise ImportError('The kerberos library has not been installed') self.service = service diff --git a/cassandra/sasl.py b/cassandra/sasl.py new file mode 100644 index 0000000000..b2c0f03ed2 --- /dev/null +++ b/cassandra/sasl.py @@ -0,0 +1,301 @@ +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Internal SASL client implementation. + +This module provides SASL authentication support for the driver without +requiring the external pure-sasl dependency. It implements the PLAIN and +GSSAPI mechanisms which are used by the driver. + +This implementation is based on pure-sasl (https://github.com/thobbs/pure-sasl) +which is licensed under the MIT License. +""" + +import base64 +import platform +import struct + +try: + import kerberos + _have_kerberos = True +except ImportError: + _have_kerberos = False + +if platform.system() == 'Windows': + try: + import winkerberos as kerberos + # Fix for different capitalisation in winkerberos method name + kerberos.authGSSClientUserName = kerberos.authGSSClientUsername + _have_kerberos = True + except ImportError: + # winkerberos is an optional dependency on Windows; fall back to non-kerberos auth + pass + + +class SASLError(Exception): + """ + Represents an error in configuration or usage of the SASL client. + """ + pass + + +class SASLProtocolException(Exception): + """ + Raised when an error occurs during SASL negotiation. + """ + pass + + +class QOP: + """Quality of Protection constants.""" + AUTH = b'auth' + AUTH_INT = b'auth-int' + AUTH_CONF = b'auth-conf' + + all = (AUTH, AUTH_INT, AUTH_CONF) + + bit_map = {1: AUTH, 2: AUTH_INT, 4: AUTH_CONF} + name_map = {AUTH: 1, AUTH_INT: 2, AUTH_CONF: 4} + + @classmethod + def names_from_bitmask(cls, byt): + return set(name for bit, name in cls.bit_map.items() if bit & byt) + + @classmethod + def flag_from_name(cls, name): + return cls.name_map[name] + + +def _b(s): + """Convert string to bytes if necessary.""" + if isinstance(s, bytes): + return s + return s.encode("utf-8") + + +class BaseSASLMechanism: + """Base class for SASL mechanisms.""" + + name = None + complete = False + qop = QOP.AUTH + + def __init__(self, sasl_client, **props): + self.sasl = sasl_client + + def process(self, challenge=None): + """Process a challenge and return the response.""" + raise NotImplementedError() + + def dispose(self): + """Clear sensitive data.""" + pass + + +class PlainMechanism(BaseSASLMechanism): + """PLAIN SASL mechanism for username/password authentication.""" + + name = 'PLAIN' + + def __init__(self, sasl_client, username=None, password=None, identity='', **props): + super().__init__(sasl_client) + self.identity = identity + self.username = username + self.password = password + + def process(self, challenge=None): + self.complete = True + auth_id = self.sasl.authorization_id or self.identity + return b''.join((_b(auth_id), b'\x00', _b(self.username), b'\x00', _b(self.password))) + + def dispose(self): + self.password = None + + +class GSSAPIMechanism(BaseSASLMechanism): + """GSSAPI (Kerberos) SASL mechanism.""" + + name = 'GSSAPI' + + def __init__(self, sasl_client, principal=None, **props): + super().__init__(sasl_client) + if not _have_kerberos: + raise SASLError('kerberos module not installed, GSSAPI unavailable') + + self.user = None + self._have_negotiated_details = False + self.host = self.sasl.host + self.service = self.sasl.service + self.principal = principal + self.max_buffer = sasl_client.max_buffer + + krb_service = '@'.join((self.service, self.host)) + try: + _, self.context = kerberos.authGSSClientInit(service=krb_service, + principal=self.principal) + except TypeError: + if self.principal is not None: + raise SASLError("kerberos library does not support principal parameter") + _, self.context = kerberos.authGSSClientInit(service=krb_service) + + def _pick_qop(self, server_qop_set): + """Choose QOP based on user requirements and server offerings.""" + user_qops = set(_b(qop) if isinstance(qop, str) else qop for qop in self.sasl.qops) + available_qops = user_qops & server_qop_set + if not available_qops: + raise SASLProtocolException( + f"No common QOP available. User requested: {user_qops}, server offered: {server_qop_set}") + + # Pick strongest available QOP + for qop in (QOP.AUTH_CONF, QOP.AUTH_INT, QOP.AUTH): + if qop in available_qops: + self.qop = qop + break + + def process(self, challenge=None): + if not self._have_negotiated_details: + kerberos.authGSSClientStep(self.context, '') + _negotiated_details = kerberos.authGSSClientResponse(self.context) + self._have_negotiated_details = True + return base64.b64decode(_negotiated_details) + + challenge_b64 = base64.b64encode(challenge).decode('ascii') + + if self.user is None: + ret = kerberos.authGSSClientStep(self.context, challenge_b64) + if ret == kerberos.AUTH_GSS_COMPLETE: + self.user = kerberos.authGSSClientUserName(self.context) + return b'' + else: + response = kerberos.authGSSClientResponse(self.context) + if response: + response = base64.b64decode(response) + else: + response = b'' + return response + + # Final step: negotiate QOP + kerberos.authGSSClientUnwrap(self.context, challenge_b64) + data = kerberos.authGSSClientResponse(self.context) + plaintext_data = base64.b64decode(data) + if len(plaintext_data) != 4: + raise SASLProtocolException("Bad response from server") + + word, = struct.unpack('!I', plaintext_data) + qop_bits = word >> 24 + max_length = word & 0xffffff + server_offered_qops = QOP.names_from_bitmask(qop_bits) + self._pick_qop(server_offered_qops) + + self.max_buffer = min(self.max_buffer, max_length) + + # Build response: + # byte 0: the selected qop (1=auth, 2=auth-int, 4=auth-conf) + # byte 1-3: max buffer size (big endian) + # rest: authorization user name in UTF-8 + auth_id = self.sasl.authorization_id or self.user + fmt = '!I' + str(len(auth_id)) + 's' + word = QOP.flag_from_name(self.qop) << 24 | self.max_buffer + out = struct.pack(fmt, word, _b(auth_id)) + + encoded = base64.b64encode(out).decode('ascii') + kerberos.authGSSClientWrap(self.context, encoded) + response = kerberos.authGSSClientResponse(self.context) + self.complete = True + return base64.b64decode(response) + + def dispose(self): + if hasattr(self, 'context'): + kerberos.authGSSClientClean(self.context) + + +# Registry of available mechanisms +_mechanisms = { + 'PLAIN': PlainMechanism, +} + +if _have_kerberos: + _mechanisms['GSSAPI'] = GSSAPIMechanism + + +class SASLClient: + """ + A SASL client for authentication with Cassandra/ScyllaDB. + + This class provides a simplified interface for SASL authentication, + supporting PLAIN and GSSAPI mechanisms. + """ + + def __init__(self, host, service=None, mechanism=None, authorization_id=None, + callback=None, qops=QOP.all, mutual_auth=False, max_buffer=65536, + **mechanism_props): + """ + Initialize a SASL client. + + :param host: Name of the SASL server (typically FQDN) + :param service: Service name (e.g., 'cassandra', 'dse') + :param mechanism: SASL mechanism to use ('PLAIN', 'GSSAPI') + :param authorization_id: Optional authorization ID + :param qops: Allowed quality of protection options + :param max_buffer: Maximum buffer size + :param mechanism_props: Additional mechanism-specific properties + """ + self.host = host + self.service = service + self.authorization_id = authorization_id + self.mechanism = mechanism + self.callback = callback + self.qops = set(qops) + self.mutual_auth = mutual_auth + self.max_buffer = max_buffer + self._mech_props = mechanism_props + self._chosen_mech = None + + if self.mechanism is not None: + if mechanism not in _mechanisms: + if mechanism == 'GSSAPI' and not _have_kerberos: + raise SASLError('kerberos module not installed, GSSAPI unavailable') + raise SASLError(f'Unknown mechanism {mechanism}') + mech_class = _mechanisms[mechanism] + self._chosen_mech = mech_class(self, **self._mech_props) + + def process(self, challenge=None): + """ + Process a challenge from the server during SASL negotiation. + + :param challenge: Challenge bytes from the server, or None for initial response + :return: Response bytes to send to the server + """ + if not self._chosen_mech: + raise SASLError("A mechanism has not been chosen yet") + return self._chosen_mech.process(challenge) + + @property + def complete(self): + """Check if SASL negotiation has completed successfully.""" + if not self._chosen_mech: + raise SASLError("A mechanism has not been chosen yet") + return self._chosen_mech.complete + + @property + def qop(self): + """Return the negotiated quality of protection.""" + if not self._chosen_mech: + raise SASLError("A mechanism has not been chosen yet") + return self._chosen_mech.qop + + def dispose(self): + """Clear sensitive data.""" + if self._chosen_mech: + self._chosen_mech.dispose() diff --git a/docs/security.rst b/docs/security.rst index 5c8645e685..cf30911770 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -31,8 +31,7 @@ For example, suppose Cassandra is setup with its default Custom Authenticators ^^^^^^^^^^^^^^^^^^^^^ If you're using something other than Cassandra's ``PasswordAuthenticator``, -:class:`~.SaslAuthProvider` is provided for generic SASL authentication mechanisms, -utilizing the ``pure-sasl`` package. +:class:`~.SaslAuthProvider` is provided for generic SASL authentication mechanisms. If these do not suit your needs, you may need to create your own subclasses of :class:`~.AuthProvider` and :class:`~.Authenticator`. You can use the Sasl classes as example implementations. diff --git a/pyproject.toml b/pyproject.toml index b19b38934c..436631a77b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ auth-kerberos = [ dev = [ "pytest~=8.0", "PyYAML", - "pure-sasl", "twisted[tls]", "gevent", "eventlet>=0.33.3", diff --git a/tests/integration/standard/test_authentication.py b/tests/integration/standard/test_authentication.py index eb8019bf65..f86217e7d7 100644 --- a/tests/integration/standard/test_authentication.py +++ b/tests/integration/standard/test_authentication.py @@ -163,8 +163,6 @@ class SaslAuthenticatorTests(AuthenticationTests): def setUp(self): if PROTOCOL_VERSION < 2: raise unittest.SkipTest('Sasl authentication not available for protocol v1') - if SASLClient is None: - raise unittest.SkipTest('pure-sasl is not installed') def get_authentication_provider(self, username, password): sasl_kwargs = {'service': 'cassandra', diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 776cbd6973..adf60bbef5 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -13,9 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from cassandra.auth import PlainTextAuthenticator +from cassandra.auth import PlainTextAuthenticator, SaslAuthProvider +from cassandra.sasl import SASLClient, SASLError, QOP import unittest +import pytest class TestPlainTextAuthenticator(unittest.TestCase): @@ -23,3 +25,86 @@ class TestPlainTextAuthenticator(unittest.TestCase): def test_evaluate_challenge_with_unicode_data(self): authenticator = PlainTextAuthenticator("johnӁ", "doeӁ") assert authenticator.evaluate_challenge(b'PLAIN-START') == "\x00johnӁ\x00doeӁ".encode('utf-8') + + +class TestSASLClient(unittest.TestCase): + + def test_plain_mechanism_basic(self): + client = SASLClient('localhost', service='cassandra', mechanism='PLAIN', + username='testuser', password='testpass') + response = client.process() + assert response == b'\x00testuser\x00testpass' + assert client.complete + + def test_plain_mechanism_with_authorization_id(self): + client = SASLClient('localhost', service='cassandra', mechanism='PLAIN', + username='testuser', password='testpass', + authorization_id='admin') + response = client.process() + assert response == b'admin\x00testuser\x00testpass' + assert client.complete + + def test_plain_mechanism_unicode(self): + client = SASLClient('localhost', service='cassandra', mechanism='PLAIN', + username='johnӁ', password='doeӁ') + response = client.process() + expected = b'\x00' + 'johnӁ'.encode('utf-8') + b'\x00' + 'doeӁ'.encode('utf-8') + assert response == expected + assert client.complete + + def test_unknown_mechanism(self): + with pytest.raises(SASLError, match='Unknown mechanism'): + SASLClient('localhost', service='cassandra', mechanism='UNKNOWN') + + def test_gssapi_without_kerberos(self): + # This test verifies proper error handling when kerberos is not installed + try: + import kerberos + pytest.skip('kerberos is installed, skipping this test') + except ImportError: + with pytest.raises(SASLError, match='kerberos module not installed'): + SASLClient('localhost', service='cassandra', mechanism='GSSAPI') + + def test_qop_constants(self): + assert QOP.AUTH == b'auth' + assert QOP.AUTH_INT == b'auth-int' + assert QOP.AUTH_CONF == b'auth-conf' + assert QOP.all == (b'auth', b'auth-int', b'auth-conf') + + def test_qop_bitmask_conversion(self): + # Test bitmask to names + assert QOP.names_from_bitmask(1) == {b'auth'} + assert QOP.names_from_bitmask(2) == {b'auth-int'} + assert QOP.names_from_bitmask(4) == {b'auth-conf'} + assert QOP.names_from_bitmask(7) == {b'auth', b'auth-int', b'auth-conf'} + + def test_dispose_clears_password(self): + client = SASLClient('localhost', service='cassandra', mechanism='PLAIN', + username='testuser', password='secret') + client.process() + client.dispose() + assert client._chosen_mech.password is None + + +class TestSaslAuthProvider(unittest.TestCase): + + def test_host_passthrough(self): + sasl_kwargs = {'service': 'cassandra', 'mechanism': 'PLAIN', + 'username': 'user', 'password': 'pass'} + provider = SaslAuthProvider(**sasl_kwargs) + host = 'thehostname' + authenticator = provider.new_authenticator(host) + assert authenticator.sasl.host == host + + def test_host_rejected(self): + sasl_kwargs = {'host': 'something'} + with pytest.raises(ValueError): + SaslAuthProvider(**sasl_kwargs) + + def test_initial_response(self): + sasl_kwargs = {'service': 'cassandra', 'mechanism': 'PLAIN', + 'username': 'testuser', 'password': 'testpass'} + provider = SaslAuthProvider(**sasl_kwargs) + authenticator = provider.new_authenticator('localhost') + response = authenticator.initial_response() + assert response == b'\x00testuser\x00testpass'