From 8a87dc77b3f8ef56aa5deea5a06d1782d7bc237d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20H=C3=B6rist?= Date: Sun, 22 Oct 2023 21:31:06 +0200 Subject: refactor: Rewrite channel binding code - Fix some bugs related to XEP-0388 (SASL2) --- nbxmpp/client.py | 7 ++++++ nbxmpp/connection.py | 6 +++++ nbxmpp/namespaces.py | 3 ++- nbxmpp/protocol.py | 31 +++++++++++++++++++----- nbxmpp/sasl.py | 67 +++++++++++++++++++++++++++++++++------------------- nbxmpp/structs.py | 6 +++++ nbxmpp/tcp.py | 19 +++++++++++++++ 7 files changed, 108 insertions(+), 31 deletions(-) diff --git a/nbxmpp/client.py b/nbxmpp/client.py index c777017..5a8c02e 100644 --- a/nbxmpp/client.py +++ b/nbxmpp/client.py @@ -20,6 +20,7 @@ from typing import Any from typing import Optional from gi.repository import GLib +from gi.repository import Gio from nbxmpp.namespaces import Namespace from nbxmpp.http import HTTPSession @@ -222,6 +223,12 @@ class Client(Observable): def ciphersuite(self): return self._con.ciphersuite + def get_channel_binding_data( + self, + type_: Gio.TlsChannelBindingType + ) -> Optional[bytes]: + return self._con.get_channel_binding_data(type_) + def set_ignore_tls_errors(self, ignore): self._ignore_tls_errors = ignore diff --git a/nbxmpp/connection.py b/nbxmpp/connection.py index 3eb54cf..e4f1d82 100644 --- a/nbxmpp/connection.py +++ b/nbxmpp/connection.py @@ -74,6 +74,12 @@ class Connection(Observable): def ciphersuite(self) -> Optional[int]: return None + def get_channel_binding_data( + self, + type_: Gio.TlsChannelBindingType # pylint: disable=unused-argument + ) -> Optional[bytes]: + return None + @property def local_address(self): return self._local_address diff --git a/nbxmpp/namespaces.py b/nbxmpp/namespaces.py index dc8a661..5a1310e 100644 --- a/nbxmpp/namespaces.py +++ b/nbxmpp/namespaces.py @@ -43,6 +43,7 @@ class _Namespaces: CAPS: str = 'http://jabber.org/protocol/caps' CAPTCHA: str = 'urn:xmpp:captcha' CARBONS: str = 'urn:xmpp:carbons:2' + CHANNEL_BINDING: str = 'urn:xmpp:sasl-cb:0' CHATMARKERS: str = 'urn:xmpp:chat-markers:0' CHATSTATES: str = 'http://jabber.org/protocol/chatstates' CLIENT: str = 'jabber:client' @@ -147,7 +148,7 @@ class _Namespaces: ROSTER_VER: str = 'urn:xmpp:features:rosterver' RSM: str = 'http://jabber.org/protocol/rsm' SASL: str = 'urn:ietf:params:xml:ns:xmpp-sasl' - SASL2: str = 'urn:xmpp:sasl:1' + SASL2: str = 'urn:xmpp:sasl:2' SEARCH: str = 'jabber:iq:search' SECLABEL: str = 'urn:xmpp:sec-label:0' SECLABEL_CATALOG: str = 'urn:xmpp:sec-label:catalog:2' diff --git a/nbxmpp/protocol.py b/nbxmpp/protocol.py index 0bcc0e9..42f3a9c 100644 --- a/nbxmpp/protocol.py +++ b/nbxmpp/protocol.py @@ -37,6 +37,7 @@ from dataclasses import dataclass from dataclasses import asdict from gi.repository import GLib +from gi.repository import Gio import idna from nbxmpp.xmppiri import clean_iri @@ -60,10 +61,10 @@ def ascii_upper(s): SASL_AUTH_MECHS = [ 'SCRAM-SHA-512-PLUS', - 'SCRAM-SHA-512', 'SCRAM-SHA-256-PLUS', - 'SCRAM-SHA-256', 'SCRAM-SHA-1-PLUS', + 'SCRAM-SHA-512', + 'SCRAM-SHA-256', 'SCRAM-SHA-1', 'GSSAPI', 'PLAIN', @@ -1866,14 +1867,15 @@ class Features(Node): return self.getTag('mechanisms', namespace=Namespace.SASL) is not None def has_sasl_2(self): - return self.getTag('mechanisms', namespace=Namespace.SASL2) is not None + return self.getTag('authentication', namespace=Namespace.SASL2) is not None def get_mechs(self) -> set[str]: - mechanisms = self.getTag('mechanisms', namespace=Namespace.SASL2) + mechanisms = self.getTag('authentication', namespace=Namespace.SASL2) if mechanisms is None: mechanisms = self.getTag('mechanisms', namespace=Namespace.SASL) - if mechanisms is None: - return set() + + if mechanisms is None: + return set() mechanisms = mechanisms.getTags('mechanism') return set(mech.getData() for mech in mechanisms) @@ -1907,6 +1909,23 @@ class Features(Node): def has_anonymous(self): return 'ANONYMOUS' in self.get_mechs() + def get_channel_binding_type(self) -> Optional[Gio.TlsChannelBindingType]: + sasl_cb = self.getTag('sasl-channel-binding', + namespace=Namespace.CHANNEL_BINDING) + if sasl_cb is None: + return None + + exporter = sasl_cb.getTag( + 'channel-binding', attrs={'type': 'tls-exporter'}) + if exporter is not None: + return Gio.TlsChannelBindingType.EXPORTER + + server_end_point = sasl_cb.getTag( + 'channel-binding', attrs={'type': 'tls-server-end-point'}) + if server_end_point is not None: + return Gio.TlsChannelBindingType.SERVER_END_POINT + return None + class ErrorNode(Node): """ diff --git a/nbxmpp/sasl.py b/nbxmpp/sasl.py index 3fbd935..1470ec3 100644 --- a/nbxmpp/sasl.py +++ b/nbxmpp/sasl.py @@ -25,10 +25,13 @@ import logging import hashlib from hashlib import pbkdf2_hmac +from gi.repository import Gio + from nbxmpp.namespaces import Namespace from nbxmpp.protocol import Node from nbxmpp.protocol import SASL_ERROR_CONDITIONS from nbxmpp.protocol import SASL_AUTH_MECHS +from nbxmpp.structs import ChannelBindingData from nbxmpp.util import b64decode from nbxmpp.util import b64encode from nbxmpp.util import LogAdapter @@ -100,23 +103,41 @@ class SASL: elif stanza.getName() == 'success': self._on_success(stanza) + def _get_channel_binding_data(self, features) -> Optional[ChannelBindingData]: + if self._client.tls_version != Gio.TlsProtocolVersion.TLS_1_3: + return None + + binding_type = features.get_channel_binding_type() + if binding_type is None: + return None + + channel_binding_data = self._client.get_channel_binding_data(binding_type) + if channel_binding_data is None: + return None + + return ChannelBindingData(binding_type, channel_binding_data) + def start_auth(self, features): + self._mechanism = None self._allowed_mechs = self._client.mechs self._enabled_mechs = self._allowed_mechs - self._mechanism = None self._sasl_ns = Namespace.SASL if features.has_sasl_2(): self._sasl_ns = Namespace.SASL2 + self._log.info('Using %s', self._sasl_ns) + self._error = None - # -PLUS variants need TLS channel binding data - # This is currently not supported via GLib - self._enabled_mechs.discard('SCRAM-SHA-1-PLUS') - self._enabled_mechs.discard('SCRAM-SHA-256-PLUS') - self._enabled_mechs.discard('SCRAM-SHA-512-PLUS') - # channel_binding_data = None + channel_binding_data = None + # Segfaults see https://gitlab.gnome.org/GNOME/pygobject/-/issues/603 + # So for now channel binding is deactivated + # channel_binding_data = self._get_channel_binding_data(features) + if channel_binding_data is None: + self._enabled_mechs.discard('SCRAM-SHA-1-PLUS') + self._enabled_mechs.discard('SCRAM-SHA-256-PLUS') + self._enabled_mechs.discard('SCRAM-SHA-512-PLUS') if not GSSAPI_AVAILABLE: self._enabled_mechs.discard('GSSAPI') @@ -146,10 +167,7 @@ class SASL: self._log.info('Chosen auth mechanism: %s', chosen_mechanism) - if chosen_mechanism in ('SCRAM-SHA-512', - 'SCRAM-SHA-256', - 'SCRAM-SHA-1', - 'PLAIN'): + if chosen_mechanism.startswith(('SCRAM', 'PLAIN')): if not self._password: self._on_sasl_finished(False, 'no-password') return @@ -159,6 +177,10 @@ class SASL: self._password, domain_based_name or self._client.domain) + if (isinstance(self._mechanism, SCRAM) and + channel_binding_data is not None): + self._mechanism.set_channel_binding_data(channel_binding_data) + try: self._send_initiate() except AuthFail as error: @@ -340,18 +362,19 @@ class GSSAPI(BaseMechanism): class SCRAM(BaseMechanism): name = '' - _channel_binding = '' _hash_method = '' def __init__(self, *args, **kwargs) -> None: BaseMechanism.__init__(self, *args, **kwargs) - self._channel_binding_data = None + self._channel_binding_data: ChannelBindingData | None = None + self._gs2_header = 'n,,' self._client_nonce = '%x' % int(binascii.hexlify(os.urandom(24)), 16) self._client_first_message_bare = None self._server_signature = None - def set_channel_binding_data(self, data: bytes) -> None: + def set_channel_binding_data(self, data: ChannelBindingData) -> None: self._channel_binding_data = data + self._gs2_header = f'p={data.type},,' @property def nonce_length(self) -> int: @@ -360,9 +383,10 @@ class SCRAM(BaseMechanism): @property def _b64_channel_binding_data(self) -> str: if self.name.endswith('PLUS'): - return b64encode(b'%s%s' % (self._channel_binding.encode(), - self._channel_binding_data)) - return b64encode(self._channel_binding) + assert self._channel_binding_data is not None + return b64encode(b'%s%s' % (self._gs2_header.encode(), + self._channel_binding_data.data)) + return b64encode(self._gs2_header) @staticmethod def _scram_parse(scram_data: str) -> dict[str, str]: @@ -371,7 +395,8 @@ class SCRAM(BaseMechanism): def get_initiate_data(self) -> str: self._client_first_message_bare = 'n=%s,r=%s' % (self._username, self._client_nonce) - client_first_message = '%s%s' % (self._channel_binding, + + client_first_message = '%s%s' % (self._gs2_header, self._client_first_message_bare) return b64encode(client_first_message) @@ -442,40 +467,34 @@ class SCRAM(BaseMechanism): class SCRAM_SHA_1(SCRAM): name = 'SCRAM-SHA-1' - _channel_binding = 'n,,' _hash_method = 'sha1' class SCRAM_SHA_1_PLUS(SCRAM_SHA_1): name = 'SCRAM-SHA-1-PLUS' - _channel_binding = 'p=tls-unique,,' class SCRAM_SHA_256(SCRAM): name = 'SCRAM-SHA-256' - _channel_binding = 'n,,' _hash_method = 'sha256' class SCRAM_SHA_256_PLUS(SCRAM_SHA_256): name = 'SCRAM-SHA-256-PLUS' - _channel_binding = 'p=tls-unique,,' class SCRAM_SHA_512(SCRAM): name = 'SCRAM-SHA-512' - _channel_binding = 'n,,' _hash_method = 'sha512' class SCRAM_SHA_512_PLUS(SCRAM_SHA_512): name = 'SCRAM-SHA-512-PLUS' - _channel_binding = 'p=tls-unique,,' class AuthFail(Exception): diff --git a/nbxmpp/structs.py b/nbxmpp/structs.py index fa6d00d..452a017 100644 --- a/nbxmpp/structs.py +++ b/nbxmpp/structs.py @@ -1346,3 +1346,9 @@ class XHTMLData: if body is not None: return str(body) return str(self._bodys.popitem()[1]) + + +@dataclass +class ChannelBindingData: + type: str + data: bytes diff --git a/nbxmpp/tcp.py b/nbxmpp/tcp.py index 1a48b7e..4913a32 100644 --- a/nbxmpp/tcp.py +++ b/nbxmpp/tcp.py @@ -86,6 +86,25 @@ class TCPConnection(Connection): tls_con = self._con.get_base_io_stream() return tls_con.get_ciphersuite_name() + def get_channel_binding_data( + self, + type_: Gio.TlsChannelBindingType + ) -> Optional[bytes]: + if self._con is None: + return None + + tls_con = self._con.get_base_io_stream() + + try: + success, data = tls_con.get_channel_binding_data(type_) + except Exception as error: + self._log.warning('Unable to get channel binding data: %s', error) + return None + + if not success: + return None + return data + def connect(self): self.state = TCPState.CONNECTING -- cgit v1.2.3