Skip to content

Commit fb19b65

Browse files
committed
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.
1 parent d19b099 commit fb19b65

6 files changed

Lines changed: 393 additions & 27 deletions

File tree

cassandra/auth.py

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,7 @@
2121
except ImportError:
2222
_have_kerberos = False
2323

24-
try:
25-
from puresasl.client import SASLClient
26-
_have_puresasl = True
27-
except ImportError:
28-
_have_puresasl = False
29-
30-
try:
31-
from puresasl.client import SASLClient
32-
except ImportError:
33-
SASLClient = None
24+
from cassandra.sasl import SASLClient
3425

3526
log = logging.getLogger(__name__)
3627

@@ -184,8 +175,6 @@ class SaslAuthProvider(AuthProvider):
184175
"""
185176

186177
def __init__(self, **sasl_kwargs):
187-
if SASLClient is None:
188-
raise ImportError('The puresasl library has not been installed')
189178
if 'host' in sasl_kwargs:
190179
raise ValueError("kwargs should not contain 'host' since it is passed dynamically to new_authenticator")
191180
self.sasl_kwargs = sasl_kwargs
@@ -196,15 +185,12 @@ def new_authenticator(self, host):
196185

197186
class SaslAuthenticator(Authenticator):
198187
"""
199-
A pass-through :class:`~.Authenticator` using the third party package
200-
'pure-sasl' for authentication
188+
A :class:`~.Authenticator` using SASL for authentication
201189
202190
.. versionadded:: 2.1.4
203191
"""
204192

205193
def __init__(self, host, service, mechanism='GSSAPI', **sasl_kwargs):
206-
if SASLClient is None:
207-
raise ImportError('The puresasl library has not been installed')
208194
self.sasl = SASLClient(host, service, mechanism, **sasl_kwargs)
209195

210196
def initial_response(self):
@@ -225,15 +211,13 @@ class DSEGSSAPIAuthProvider(AuthProvider):
225211
def __init__(self, service='dse', qops=('auth',), resolve_host_name=True, **properties):
226212
"""
227213
:param service: name of the service
228-
:param qops: iterable of "Quality of Protection" allowed; see ``puresasl.QOP``
214+
:param qops: iterable of "Quality of Protection" allowed; see ``cassandra.sasl.QOP``
229215
:param resolve_host_name: boolean flag indicating whether the authenticator should reverse-lookup an FQDN when
230216
creating a new authenticator. Default is ``True``, which will resolve, or return the numeric address if there is no PTR
231217
record. Setting ``False`` creates the authenticator with the numeric address known by Cassandra
232-
:param properties: additional keyword properties to pass for the ``puresasl.mechanisms.GSSAPIMechanism`` class.
233-
Presently, 'principal' (user) is the only one referenced in the ``pure-sasl`` implementation
218+
:param properties: additional keyword properties to pass for the GSSAPI mechanism.
219+
Presently, 'principal' (user) is the only one referenced in the implementation
234220
"""
235-
if not _have_puresasl:
236-
raise ImportError('The puresasl library has not been installed')
237221
if not _have_kerberos:
238222
raise ImportError('The kerberos library has not been installed')
239223
self.service = service

cassandra/sasl.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
# Copyright DataStax, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""
15+
Internal SASL client implementation.
16+
17+
This module provides SASL authentication support for the driver without
18+
requiring the external pure-sasl dependency. It implements the PLAIN and
19+
GSSAPI mechanisms which are used by the driver.
20+
21+
This implementation is based on pure-sasl (https://github.com/thobbs/pure-sasl)
22+
which is licensed under the MIT License.
23+
"""
24+
25+
import base64
26+
import platform
27+
import struct
28+
29+
try:
30+
import kerberos
31+
_have_kerberos = True
32+
except ImportError:
33+
_have_kerberos = False
34+
35+
if platform.system() == 'Windows':
36+
try:
37+
import winkerberos as kerberos
38+
# Fix for different capitalisation in winkerberos method name
39+
kerberos.authGSSClientUserName = kerberos.authGSSClientUsername
40+
_have_kerberos = True
41+
except ImportError:
42+
# winkerberos is an optional dependency on Windows; fall back to non-kerberos auth
43+
pass
44+
45+
46+
class SASLError(Exception):
47+
"""
48+
Represents an error in configuration or usage of the SASL client.
49+
"""
50+
pass
51+
52+
53+
class SASLProtocolException(Exception):
54+
"""
55+
Raised when an error occurs during SASL negotiation.
56+
"""
57+
pass
58+
59+
60+
class QOP:
61+
"""Quality of Protection constants."""
62+
AUTH = b'auth'
63+
AUTH_INT = b'auth-int'
64+
AUTH_CONF = b'auth-conf'
65+
66+
all = (AUTH, AUTH_INT, AUTH_CONF)
67+
68+
bit_map = {1: AUTH, 2: AUTH_INT, 4: AUTH_CONF}
69+
name_map = {AUTH: 1, AUTH_INT: 2, AUTH_CONF: 4}
70+
71+
@classmethod
72+
def names_from_bitmask(cls, byt):
73+
return set(name for bit, name in cls.bit_map.items() if bit & byt)
74+
75+
@classmethod
76+
def flag_from_name(cls, name):
77+
return cls.name_map[name]
78+
79+
80+
def _b(s):
81+
"""Convert string to bytes if necessary."""
82+
if isinstance(s, bytes):
83+
return s
84+
return s.encode("utf-8")
85+
86+
87+
class BaseSASLMechanism:
88+
"""Base class for SASL mechanisms."""
89+
90+
name = None
91+
complete = False
92+
qop = QOP.AUTH
93+
94+
def __init__(self, sasl_client, **props):
95+
self.sasl = sasl_client
96+
97+
def process(self, challenge=None):
98+
"""Process a challenge and return the response."""
99+
raise NotImplementedError()
100+
101+
def dispose(self):
102+
"""Clear sensitive data."""
103+
pass
104+
105+
106+
class PlainMechanism(BaseSASLMechanism):
107+
"""PLAIN SASL mechanism for username/password authentication."""
108+
109+
name = 'PLAIN'
110+
111+
def __init__(self, sasl_client, username=None, password=None, identity='', **props):
112+
super().__init__(sasl_client)
113+
self.identity = identity
114+
self.username = username
115+
self.password = password
116+
117+
def process(self, challenge=None):
118+
self.complete = True
119+
auth_id = self.sasl.authorization_id or self.identity
120+
return b''.join((_b(auth_id), b'\x00', _b(self.username), b'\x00', _b(self.password)))
121+
122+
def dispose(self):
123+
self.password = None
124+
125+
126+
class GSSAPIMechanism(BaseSASLMechanism):
127+
"""GSSAPI (Kerberos) SASL mechanism."""
128+
129+
name = 'GSSAPI'
130+
131+
def __init__(self, sasl_client, principal=None, **props):
132+
super().__init__(sasl_client)
133+
if not _have_kerberos:
134+
raise SASLError('kerberos module not installed, GSSAPI unavailable')
135+
136+
self.user = None
137+
self._have_negotiated_details = False
138+
self.host = self.sasl.host
139+
self.service = self.sasl.service
140+
self.principal = principal
141+
self.max_buffer = sasl_client.max_buffer
142+
143+
krb_service = '@'.join((self.service, self.host))
144+
try:
145+
_, self.context = kerberos.authGSSClientInit(service=krb_service,
146+
principal=self.principal)
147+
except TypeError:
148+
if self.principal is not None:
149+
raise SASLError("kerberos library does not support principal parameter")
150+
_, self.context = kerberos.authGSSClientInit(service=krb_service)
151+
152+
def _pick_qop(self, server_qop_set):
153+
"""Choose QOP based on user requirements and server offerings."""
154+
user_qops = set(_b(qop) if isinstance(qop, str) else qop for qop in self.sasl.qops)
155+
available_qops = user_qops & server_qop_set
156+
if not available_qops:
157+
raise SASLProtocolException(
158+
f"No common QOP available. User requested: {user_qops}, server offered: {server_qop_set}")
159+
160+
# Pick strongest available QOP
161+
for qop in (QOP.AUTH_CONF, QOP.AUTH_INT, QOP.AUTH):
162+
if qop in available_qops:
163+
self.qop = qop
164+
break
165+
166+
def process(self, challenge=None):
167+
if not self._have_negotiated_details:
168+
kerberos.authGSSClientStep(self.context, '')
169+
_negotiated_details = kerberos.authGSSClientResponse(self.context)
170+
self._have_negotiated_details = True
171+
return base64.b64decode(_negotiated_details)
172+
173+
challenge_b64 = base64.b64encode(challenge).decode('ascii')
174+
175+
if self.user is None:
176+
ret = kerberos.authGSSClientStep(self.context, challenge_b64)
177+
if ret == kerberos.AUTH_GSS_COMPLETE:
178+
self.user = kerberos.authGSSClientUserName(self.context)
179+
return b''
180+
else:
181+
response = kerberos.authGSSClientResponse(self.context)
182+
if response:
183+
response = base64.b64decode(response)
184+
else:
185+
response = b''
186+
return response
187+
188+
# Final step: negotiate QOP
189+
kerberos.authGSSClientUnwrap(self.context, challenge_b64)
190+
data = kerberos.authGSSClientResponse(self.context)
191+
plaintext_data = base64.b64decode(data)
192+
if len(plaintext_data) != 4:
193+
raise SASLProtocolException("Bad response from server")
194+
195+
word, = struct.unpack('!I', plaintext_data)
196+
qop_bits = word >> 24
197+
max_length = word & 0xffffff
198+
server_offered_qops = QOP.names_from_bitmask(qop_bits)
199+
self._pick_qop(server_offered_qops)
200+
201+
self.max_buffer = min(self.max_buffer, max_length)
202+
203+
# Build response:
204+
# byte 0: the selected qop (1=auth, 2=auth-int, 4=auth-conf)
205+
# byte 1-3: max buffer size (big endian)
206+
# rest: authorization user name in UTF-8
207+
auth_id = self.sasl.authorization_id or self.user
208+
fmt = '!I' + str(len(auth_id)) + 's'
209+
word = QOP.flag_from_name(self.qop) << 24 | self.max_buffer
210+
out = struct.pack(fmt, word, _b(auth_id))
211+
212+
encoded = base64.b64encode(out).decode('ascii')
213+
kerberos.authGSSClientWrap(self.context, encoded)
214+
response = kerberos.authGSSClientResponse(self.context)
215+
self.complete = True
216+
return base64.b64decode(response)
217+
218+
def dispose(self):
219+
if hasattr(self, 'context'):
220+
kerberos.authGSSClientClean(self.context)
221+
222+
223+
# Registry of available mechanisms
224+
_mechanisms = {
225+
'PLAIN': PlainMechanism,
226+
}
227+
228+
if _have_kerberos:
229+
_mechanisms['GSSAPI'] = GSSAPIMechanism
230+
231+
232+
class SASLClient:
233+
"""
234+
A SASL client for authentication with Cassandra/ScyllaDB.
235+
236+
This class provides a simplified interface for SASL authentication,
237+
supporting PLAIN and GSSAPI mechanisms.
238+
"""
239+
240+
def __init__(self, host, service=None, mechanism=None, authorization_id=None,
241+
callback=None, qops=QOP.all, mutual_auth=False, max_buffer=65536,
242+
**mechanism_props):
243+
"""
244+
Initialize a SASL client.
245+
246+
:param host: Name of the SASL server (typically FQDN)
247+
:param service: Service name (e.g., 'cassandra', 'dse')
248+
:param mechanism: SASL mechanism to use ('PLAIN', 'GSSAPI')
249+
:param authorization_id: Optional authorization ID
250+
:param qops: Allowed quality of protection options
251+
:param max_buffer: Maximum buffer size
252+
:param mechanism_props: Additional mechanism-specific properties
253+
"""
254+
self.host = host
255+
self.service = service
256+
self.authorization_id = authorization_id
257+
self.mechanism = mechanism
258+
self.callback = callback
259+
self.qops = set(qops)
260+
self.mutual_auth = mutual_auth
261+
self.max_buffer = max_buffer
262+
self._mech_props = mechanism_props
263+
self._chosen_mech = None
264+
265+
if self.mechanism is not None:
266+
if mechanism not in _mechanisms:
267+
if mechanism == 'GSSAPI' and not _have_kerberos:
268+
raise SASLError('kerberos module not installed, GSSAPI unavailable')
269+
raise SASLError(f'Unknown mechanism {mechanism}')
270+
mech_class = _mechanisms[mechanism]
271+
self._chosen_mech = mech_class(self, **self._mech_props)
272+
273+
def process(self, challenge=None):
274+
"""
275+
Process a challenge from the server during SASL negotiation.
276+
277+
:param challenge: Challenge bytes from the server, or None for initial response
278+
:return: Response bytes to send to the server
279+
"""
280+
if not self._chosen_mech:
281+
raise SASLError("A mechanism has not been chosen yet")
282+
return self._chosen_mech.process(challenge)
283+
284+
@property
285+
def complete(self):
286+
"""Check if SASL negotiation has completed successfully."""
287+
if not self._chosen_mech:
288+
raise SASLError("A mechanism has not been chosen yet")
289+
return self._chosen_mech.complete
290+
291+
@property
292+
def qop(self):
293+
"""Return the negotiated quality of protection."""
294+
if not self._chosen_mech:
295+
raise SASLError("A mechanism has not been chosen yet")
296+
return self._chosen_mech.qop
297+
298+
def dispose(self):
299+
"""Clear sensitive data."""
300+
if self._chosen_mech:
301+
self._chosen_mech.dispose()

docs/security.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ For example, suppose Cassandra is setup with its default
3131
Custom Authenticators
3232
^^^^^^^^^^^^^^^^^^^^^
3333
If you're using something other than Cassandra's ``PasswordAuthenticator``,
34-
:class:`~.SaslAuthProvider` is provided for generic SASL authentication mechanisms,
35-
utilizing the ``pure-sasl`` package.
34+
:class:`~.SaslAuthProvider` is provided for generic SASL authentication mechanisms.
3635
If these do not suit your needs, you may need to create your own subclasses of
3736
:class:`~.AuthProvider` and :class:`~.Authenticator`. You can use the Sasl classes
3837
as example implementations.

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ auth-kerberos = [
4444
dev = [
4545
"pytest~=8.0",
4646
"PyYAML",
47-
"pure-sasl",
4847
"twisted[tls]",
4948
"gevent",
5049
"eventlet>=0.33.3",

tests/integration/standard/test_authentication.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,6 @@ class SaslAuthenticatorTests(AuthenticationTests):
163163
def setUp(self):
164164
if PROTOCOL_VERSION < 2:
165165
raise unittest.SkipTest('Sasl authentication not available for protocol v1')
166-
if SASLClient is None:
167-
raise unittest.SkipTest('pure-sasl is not installed')
168166

169167
def get_authentication_provider(self, username, password):
170168
sasl_kwargs = {'service': 'cassandra',

0 commit comments

Comments
 (0)