From 532386a2b9cfd54251ad79680b42b03d776d9858 Mon Sep 17 00:00:00 2001 From: Samuel Hoffman Date: Wed, 10 Jan 2018 08:58:19 -0600 Subject: [PATCH 1/4] Support for TLS certificate verification and client certificates. (#301) If a TLS certificate fingerprint is provided, the client will check it against the SHA256 hex digest of the server's certificate. Different hash algorithms can be specified, and multiple fingerprints can be specified for networks with more than one server. --- .gitignore | 3 ++ oyoyo/client.py | 117 +++++++++++++++++++++++++++++++++++++++++++++++- src/settings.py | 16 +++++++ wolfbot.py | 6 +++ 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dc51cd8..ff37b8a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ __pycache__/ *.py[co] +# Linter files +.mypy_cache/ + # Config files botconfig.py /gamemodes.py diff --git a/oyoyo/client.py b/oyoyo/client.py index ebdc43b..208dbec 100644 --- a/oyoyo/client.py +++ b/oyoyo/client.py @@ -22,6 +22,8 @@ import threading import time import traceback import os +import hashlib +import hmac from oyoyo.parse import parse_raw_irc_command @@ -98,6 +100,11 @@ class IRCClient: self.blocking = True self.sasl_auth = False self.use_ssl = False + self.cert_verify = False + self.cert_fp = "" + self.client_certfile = None + self.client_keyfile = None + self.cipher_list = None self.server_pass = None self.lock = threading.RLock() self.stream_handler = lambda output, level=None: print(output) @@ -174,7 +181,115 @@ class IRCClient: sys.exit(1) if self.use_ssl: - self.socket = ssl.wrap_socket(self.socket) + ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + + if self.cipher_list: + try: + ctx.set_ciphers(self.cipher_list) + except Exception: + self.stream_handler("No ciphers could be selected from the cipher list. TLS is not available.", level="warning") + self.stream_handler("Use `openssl ciphers' to see which ciphers are available on this system.", level="warning") + raise + + # explicitly disable old protocols + ctx.options |= ssl.OP_NO_SSLv2 + ctx.options |= ssl.OP_NO_SSLv3 + ctx.options |= ssl.OP_NO_TLSv1 + + # explicitly disable compression (CRIME attack) + ctx.options |= ssl.OP_NO_COMPRESSION + + if sys.version_info >= (3, 6): + # TLS session tickets harm forward secrecy (this symbol is only defined in 3.6 and later) + ctx.options |= ssl.OP_NO_TICKET + + if self.cert_verify and not self.cert_fp: + ctx.verify_mode = ssl.CERT_REQUIRED + + if not self.cert_fp: + ctx.check_hostname = True + + ctx.load_default_certs() + + elif not self.cert_verify and not self.cert_fp: + self.stream_handler("NOT validating the server's TLS certificate! Set 'SSL_VERIFY=True' or define a fingerprint in 'SSL_CERTFP' in botconfig.py to enable this.", level="warning") + + if self.client_certfile: + # if client_keyfile is not specified, the ssl module will look to the + # client_certfile for it. + + try: + ctx.load_cert_chain(self.client_certfile, self.client_keyfile) + self.stream_handler("Connecting with a TLS client certificate", level="info") + except Exception as error: + self.stream_handler("Unable to load client cert/key pair: {0}".format(error), level="warning") + + try: + self.socket = ctx.wrap_socket(self.socket, server_hostname=self.host) + except Exception as error: + self.stream_handler("Error occured while connecting with TLS: {0}".format(error), level="warning") + raise + + if self.cert_fp: + algo = None + if ":" in self.cert_fp: + algo, fp = self.cert_fp.split(":") + fp = fp.split(",") + self.stream_handler("Checking server's certificate {0} hash sum".format(algo), level="info") + else: + fp = self.cert_fp.split(",") + + hashlen = {32: "md5", 40: "sha1", 56: "sha224", + 64: "sha256", 96: "sha384", 128: "sha512"} + + + peercert = self.socket.getpeercert(True) + + h = None + peercertfp = None + + if algo: + try: + h = hashlib.new(algo) + except Exception as error: + self.stream_handler("TLS certificate fingerprint verification failed: {}".format(error), level="warning") + self.stream_handler("Supported algorithms on this system: {0}".format(", ".join(hashlib.algorithms_available)), level="warning") + raise + + h.update(peercert) + peercertfp = h.hexdigest() + + matched = False + for n, fingerprint in enumerate(fp): + + if not h: + fplen = len(fingerprint) + if fplen not in hashlen: + self.stream_handler("Unable to auto-detect fingerprint #{0} ({1}) algorithm type by length".format(n, fp), level="warning") + continue + + algo = hashlen[fplen] + self.stream_handler("Checking server's certificate {0} hash sum".format(algo), level="info") + + try: + h = hashlib.new(algo) + except Exception as error: + self.stream_handler("TLS certificate fingerprint verification failed: {}".format(error), level="warning") + self.stream_handler("Supported algorithms on this system: {0}".format(", ".join(hashlib.algorithms_available)), level="warning") + raise + + h.update(peercert) + peercertfp = h.hexdigest() + + if hmac.compare_digest(fingerprint, peercertfp): + matched = fingerprint + + if not matched: + self.stream_handler("Certificate fingerprint {0} did not match any expected fingerprints".format(peercertfp), level="warning") + raise ssl.CertificateError("Certificate fingerprint does not match.") + self.stream_handler("Server certificate fingerprint matched {0} ({1})".format(matched, algo), level="info") + + self.stream_handler("Connected with cipher {0}".format(self.socket.cipher()[0]), level="info") if not self.blocking: self.socket.setblocking(0) diff --git a/src/settings.py b/src/settings.py index e8b90b1..21ef498 100644 --- a/src/settings.py +++ b/src/settings.py @@ -6,6 +6,22 @@ from collections import defaultdict, OrderedDict import botconfig LANGUAGE = 'en' + +## TLS settings +# If SSL_CERTFP is supplied, the bot will attempt to verify that with the server. If not, then the +# bot will attempt certificate verification, otherwise it will abort the connection. +# You may specify the hash algorithm to use when verifying fingerprints. If unspecified, it will +# attempt to autodetect the hash type of each fingerprint individually. (Limited to MD5, SHA1, +# SHA224, SHA384, and SHA512). +# Multiple fingerprints may be comma separated for networks with multipleservers. Check your Python +# version for suported algorithms. +# Syntax: SSL_CERTFP = "[HASH-ALGO:]fingerprint1,fingerprint2,fingerprint3" +SSL_VERIFY = True +SSL_CERTFILE = None # Client cert file to connect with in PEM format; can also contain keyfile. +SSL_KEYFILE = None # Keyfile for the certfile in PEM format. if encrypted, password will prompt on the command line. +SSL_CERTFP = None +SSL_CIPHERS = None # Custom list of available ciphers in OpenSSL cipher list format. () + MINIMUM_WAIT = 60 EXTRA_WAIT = 30 EXTRA_WAIT_JOIN = 0 # Add this many seconds to the waiting time for each !join diff --git a/wolfbot.py b/wolfbot.py index 1ff353c..4822e13 100755 --- a/wolfbot.py +++ b/wolfbot.py @@ -51,6 +51,7 @@ from oyoyo.client import IRCClient import src from src import handler from src.events import Event +import src.settings as var def main(): evt = Event("init", {}) @@ -70,6 +71,11 @@ def main(): sasl_auth=botconfig.SASL_AUTHENTICATION, server_pass=botconfig.SERVER_PASS, use_ssl=botconfig.USE_SSL, + cert_verify=var.SSL_VERIFY, + cert_fp=var.SSL_CERTFP, + client_certfile=var.SSL_CERTFILE, + client_keyfile=var.SSL_KEYFILE, + cipher_list=var.SSL_CIPHERS, connect_cb=handler.connect_callback, stream_handler=src.stream, ) From 9190a4c859a35026a69ec237601f9af1ac140129 Mon Sep 17 00:00:00 2001 From: skizzerz Date: Wed, 10 Jan 2018 12:03:05 -0700 Subject: [PATCH 2/4] Rework TLS validation a bit and support SASL EXTERNAL - Move the config comments to botconfig.py.example where they will be more useful, and move the bits in settings.py near the other IRC-related settings. - Removed support for hash types that aren't SHA-256 as we perform all the hashing on our end (doesn't matter what the server does or does not support), and this greatly simplifies the code while leaving things secure enough. - Hardcode a default cipher suite according to mozilla modern standards, as the builtin ciphersuite in python may be less secure for older python versions. - Add support for EXTERNAL auth in SASL, if a client certificate is provided. If this fails, it will fall back to PLAIN auth (to account for the case where a cert is added to the bot, but has not yet been added to NickServ, so that the bot can connect and add it to NickServ via !fsend) - Redact passwords from console/log output so that asking people to pastebin their --verbose output when reporting issues in #lykos is less fraught with peril. --- .gitignore | 3 ++ botconfig.py.example | 21 ++++++++-- oyoyo/client.py | 91 ++++++++++++-------------------------------- src/handler.py | 50 +++++++++++++++++++----- src/settings.py | 22 ++++------- 5 files changed, 93 insertions(+), 94 deletions(-) diff --git a/.gitignore b/.gitignore index ff37b8a..baf7a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ botconfig.py # Log files *.log + +# Certificates/keys +*.pem diff --git a/botconfig.py.example b/botconfig.py.example index 240fc6f..a9cda63 100644 --- a/botconfig.py.example +++ b/botconfig.py.example @@ -1,13 +1,29 @@ HOST = "chat.freenode.net" PORT = 6697 -USE_SSL = True NICK = "mywolfbot" IDENT = NICK REALNAME = NICK USERNAME = "" # For authentication; can be left blank if the same as NICK. -PASS = "my_nickserv_pass" +PASS = "my_nickserv_pass" # can be None if authenticating with client certificates (see below) SASL_AUTHENTICATION = True +USE_SSL = True +SSL_VERIFY = True +# SHA256 fingerprints of server certificates. Usually not needed, but for extra security +# you may set this. Otherwise, we validate certificates as long as they chain up to a trusted CA. +# If set, CA validation is not considered, and we validate based on the fingerprint. If the server +# is using self-signed certificates, you will want to make use of SSL_CERTFP. +# An example below is for freenode; note that certificate fingerprints can and do change over time, +# so manual adjustment may be required if you make use of this setting. +# Example of how to obtain a fingerprint: +# openssl s_client -connect chat.freenode.net:6697 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin +# The comma at the end is required if there is only one fingerprint. +#SSL_CERTFP = ("51:F4:3A:29:80:49:10:F0:23:5C:5E:F4:3B:0C:0A:6E:D9:42:BF:A1:60:89:4A:28:38:AD:CF:F7:DE:49:B4:16",) + +# For authenticating with client certificates, set these options +SSL_CERTFILE = None # Client cert file to connect with in PEM format. May contain private key as well. +SSL_KEYFILE = None # Keyfile for the certfile in PEM format + CHANNEL = "##mywolfgame" CMD_CHAR = "!" @@ -18,7 +34,6 @@ CMD_CHAR = "!" # # Note: Do not put the account and password here; they will be automatically substituted # from the USERNAME (or NICK) and PASS variables on the top of the file. - SERVER_PASS = None OWNERS = ("unaffiliated/wolfbot_admin1",) # The comma is required at the end if there is only one owner. diff --git a/oyoyo/client.py b/oyoyo/client.py index 208dbec..28b72e4 100644 --- a/oyoyo/client.py +++ b/oyoyo/client.py @@ -101,7 +101,7 @@ class IRCClient: self.sasl_auth = False self.use_ssl = False self.cert_verify = False - self.cert_fp = "" + self.cert_fp = () self.client_certfile = None self.client_keyfile = None self.cipher_list = None @@ -152,7 +152,8 @@ class IRCClient: for arg in args]), i)) msg = bytes(" ", "utf_8").join(bargs) - self.stream_handler('---> send {0}'.format(str(msg)[1:])) + logmsg = kwargs.get("log") or str(msg)[1:] + self.stream_handler('---> send {0}'.format(logmsg)) while not self.tokenbucket.consume(1): time.sleep(0.3) @@ -210,84 +211,38 @@ class IRCClient: ctx.check_hostname = True ctx.load_default_certs() - elif not self.cert_verify and not self.cert_fp: - self.stream_handler("NOT validating the server's TLS certificate! Set 'SSL_VERIFY=True' or define a fingerprint in 'SSL_CERTFP' in botconfig.py to enable this.", level="warning") + self.stream_handler("**NOT** validating the server's TLS certificate! Set SSL_VERIFY or SSL_CERTFP in botconfig.py.", level="warning") if self.client_certfile: - # if client_keyfile is not specified, the ssl module will look to the - # client_certfile for it. - + # if client_keyfile is not specified, the ssl module will look to the client_certfile for it. try: - ctx.load_cert_chain(self.client_certfile, self.client_keyfile) + # specify blank password to ensure that encrypted certs will outright fail rather than prompting for password on stdin + # in a scenario where a user does !update or !restart, they will be unable to type in such a password and effectively kill the bot + # until someone can SSH in to restart it via CLI. + ctx.load_cert_chain(self.client_certfile, self.client_keyfile, password="") self.stream_handler("Connecting with a TLS client certificate", level="info") except Exception as error: - self.stream_handler("Unable to load client cert/key pair: {0}".format(error), level="warning") + self.stream_handler("Unable to load client cert/key pair: {0}".format(error), level="error") + raise try: self.socket = ctx.wrap_socket(self.socket, server_hostname=self.host) except Exception as error: - self.stream_handler("Error occured while connecting with TLS: {0}".format(error), level="warning") + self.stream_handler("Could not connect with TLS: {0}".format(error), level="error") raise if self.cert_fp: - algo = None - if ":" in self.cert_fp: - algo, fp = self.cert_fp.split(":") - fp = fp.split(",") - self.stream_handler("Checking server's certificate {0} hash sum".format(algo), level="info") - else: - fp = self.cert_fp.split(",") - - hashlen = {32: "md5", 40: "sha1", 56: "sha224", - 64: "sha256", 96: "sha384", 128: "sha512"} - - + valid_fps = set(fp.replace(":", "").lower() for fp in self.cert_fp) peercert = self.socket.getpeercert(True) + h = hashlib.new("sha256") + h.update(peercert) + peercertfp = h.hexdigest() - h = None - peercertfp = None - - if algo: - try: - h = hashlib.new(algo) - except Exception as error: - self.stream_handler("TLS certificate fingerprint verification failed: {}".format(error), level="warning") - self.stream_handler("Supported algorithms on this system: {0}".format(", ".join(hashlib.algorithms_available)), level="warning") - raise - - h.update(peercert) - peercertfp = h.hexdigest() - - matched = False - for n, fingerprint in enumerate(fp): - - if not h: - fplen = len(fingerprint) - if fplen not in hashlen: - self.stream_handler("Unable to auto-detect fingerprint #{0} ({1}) algorithm type by length".format(n, fp), level="warning") - continue - - algo = hashlen[fplen] - self.stream_handler("Checking server's certificate {0} hash sum".format(algo), level="info") - - try: - h = hashlib.new(algo) - except Exception as error: - self.stream_handler("TLS certificate fingerprint verification failed: {}".format(error), level="warning") - self.stream_handler("Supported algorithms on this system: {0}".format(", ".join(hashlib.algorithms_available)), level="warning") - raise - - h.update(peercert) - peercertfp = h.hexdigest() - - if hmac.compare_digest(fingerprint, peercertfp): - matched = fingerprint - - if not matched: - self.stream_handler("Certificate fingerprint {0} did not match any expected fingerprints".format(peercertfp), level="warning") - raise ssl.CertificateError("Certificate fingerprint does not match.") - self.stream_handler("Server certificate fingerprint matched {0} ({1})".format(matched, algo), level="info") + if peercertfp not in valid_fps: + self.stream_handler("Certificate fingerprint {0} did not match any expected fingerprints".format(peercertfp), level="error") + raise ssl.CertificateError("Certificate fingerprint {0} did not match any expected fingerprints".format(peercertfp)) + self.stream_handler("Server certificate fingerprint matched {0}".format(peercertfp), level="info") self.stream_handler("Connected with cipher {0}".format(self.socket.cipher()[0]), level="info") @@ -301,10 +256,10 @@ class IRCClient: message = "PASS :{0}".format(self.server_pass).format( account=self.authname if self.authname else self.nickname, password=self.password) - self.send(message) + self.send(message, log="PASS :[redacted]") elif self.server_pass: message = "PASS :{0}".format(self.server_pass) - self.send(message) + self.send(message, log="PASS :[redacted]") self.send("NICK", self.nickname) self.user(self.ident, self.real_name) @@ -413,3 +368,5 @@ class IRCClient: if not next(conn): self.stream_handler("Calling sys.exit()...", level="warning") sys.exit() + +# vim: set sw=4 expandtab: diff --git a/src/handler.py b/src/handler.py index 44dee31..1b833d8 100644 --- a/src/handler.py +++ b/src/handler.py @@ -238,13 +238,23 @@ def connect_callback(cli): request_caps.add("sasl") supported_caps = set() + supported_sasl = None + selected_sasl = None @hook("cap") def on_cap(cli, svr, mynick, cmd, *caps): + nonlocal supported_sasl, selected_sasl # caps is a star because we might receive multiline in LS if cmd == "LS": for item in caps[-1].split(): # First item may or may not be *, for multiline - supported_caps.add(item.split("=")[0]) # If there's any value, we just don't care + try: + key, value = item.split("=", 1) + except ValueError: + key = item + value = None + supported_caps.add(key) + if key == "sasl" and value is not None: + supported_sasl = set(value.split(",")) if caps[0] == "*": # Multiline, don't continue yet return @@ -261,7 +271,18 @@ def connect_callback(cli): elif cmd == "ACK": if "sasl" in caps[0]: - cli.send("AUTHENTICATE PLAIN") + if var.SSL_CERTFILE: + mech = "EXTERNAL" + else: + mech = "PLAIN" + selected_sasl = mech + + if supported_sasl and mech in supported_sasl: + cli.send("AUTHENTICATE {0}".format(mech)) + else: + alog("Server does not support the SASL {0} mechanism".format(mech)) + cli.quit() + raise ValueError("Server does not support the SASL {0} mechanism".format(mech)) else: cli.send("CAP END") elif cmd == "NAK": @@ -272,11 +293,15 @@ def connect_callback(cli): if botconfig.SASL_AUTHENTICATION: @hook("authenticate") def auth_plus(cli, something, plus): + nonlocal selected_sasl if plus == "+": - account = (botconfig.USERNAME or botconfig.NICK).encode("utf-8") - password = botconfig.PASS.encode("utf-8") - auth_token = base64.b64encode(b"\0".join((account, account, password))).decode("utf-8") - cli.send("AUTHENTICATE " + auth_token) + if selected_sasl == "EXTERNAL": + cli.send("AUTHENTICATE +") + elif selected_sasl == "PLAIN": + account = (botconfig.USERNAME or botconfig.NICK).encode("utf-8") + password = botconfig.PASS.encode("utf-8") + auth_token = base64.b64encode(b"\0".join((account, account, password))).decode("utf-8") + cli.send("AUTHENTICATE " + auth_token, log="AUTHENTICATE [redacted]") @hook("903") def on_successful_auth(cli, blah, blahh, blahhh): @@ -287,9 +312,16 @@ def connect_callback(cli): @hook("906") @hook("907") def on_failure_auth(cli, *etc): - alog("Authentication failed. Did you fill the account name " - "in botconfig.USERNAME if it's different from the bot nick?") - cli.quit() + nonlocal supported_sasl, selected_sasl + if selected_sasl == "EXTERNAL" and (supported_sasl is None or "PLAIN" in supported_sasl): + # EXTERNAL failed, retry with PLAIN as we may not have set up the client cert yet + selected_sasl = "PLAIN" + alog("EXTERNAL auth failed, retrying with PLAIN... ensure the client cert is set up in NickServ") + cli.send("AUTHENTICATE PLAIN") + else: + alog("Authentication failed. Did you fill the account name " + "in botconfig.USERNAME if it's different from the bot nick?") + cli.quit() users.Bot = users.BotUser(cli, botconfig.NICK) diff --git a/src/settings.py b/src/settings.py index 21ef498..735106e 100644 --- a/src/settings.py +++ b/src/settings.py @@ -7,21 +7,6 @@ import botconfig LANGUAGE = 'en' -## TLS settings -# If SSL_CERTFP is supplied, the bot will attempt to verify that with the server. If not, then the -# bot will attempt certificate verification, otherwise it will abort the connection. -# You may specify the hash algorithm to use when verifying fingerprints. If unspecified, it will -# attempt to autodetect the hash type of each fingerprint individually. (Limited to MD5, SHA1, -# SHA224, SHA384, and SHA512). -# Multiple fingerprints may be comma separated for networks with multipleservers. Check your Python -# version for suported algorithms. -# Syntax: SSL_CERTFP = "[HASH-ALGO:]fingerprint1,fingerprint2,fingerprint3" -SSL_VERIFY = True -SSL_CERTFILE = None # Client cert file to connect with in PEM format; can also contain keyfile. -SSL_KEYFILE = None # Keyfile for the certfile in PEM format. if encrypted, password will prompt on the command line. -SSL_CERTFP = None -SSL_CIPHERS = None # Custom list of available ciphers in OpenSSL cipher list format. () - MINIMUM_WAIT = 60 EXTRA_WAIT = 30 EXTRA_WAIT_JOIN = 0 # Add this many seconds to the waiting time for each !join @@ -208,6 +193,13 @@ ACCOUNTS_ONLY = False # If True, will use only accounts for everything DISABLE_ACCOUNTS = False # If True, all account-related features are disabled. Automatically set if we discover we do not have proper ircd support for accounts # This will override ACCOUNTS_ONLY if it is set +SSL_VERIFY = True +SSL_CERTFP = () +# Tracking Mozilla's "modern" compatibility list -- https://wiki.mozilla.org/Security/Server_Side_TLS#Modern_compatibility +SSL_CIPHERS = "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" +SSL_CERTFILE = None +SSL_KEYFILE = None + NICKSERV = "NickServ" NICKSERV_IDENTIFY_COMMAND = "IDENTIFY {account} {password}" NICKSERV_GHOST_COMMAND = "GHOST {nick}" From 916d476ca5de9a524b567b901928acbc4f065806 Mon Sep 17 00:00:00 2001 From: skizzerz Date: Wed, 10 Jan 2018 12:14:02 -0700 Subject: [PATCH 3/4] Fix SASL support for ircds which don't advertise SASL types in CAP --- src/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handler.py b/src/handler.py index 1b833d8..d07a6cb 100644 --- a/src/handler.py +++ b/src/handler.py @@ -277,7 +277,7 @@ def connect_callback(cli): mech = "PLAIN" selected_sasl = mech - if supported_sasl and mech in supported_sasl: + if supported_sasl is None or mech in supported_sasl: cli.send("AUTHENTICATE {0}".format(mech)) else: alog("Server does not support the SASL {0} mechanism".format(mech)) From f988293f42d7b1e2dcc04ee4d2a6eb6e265ce5e6 Mon Sep 17 00:00:00 2001 From: skizzerz Date: Wed, 10 Jan 2018 12:30:03 -0700 Subject: [PATCH 4/4] rm extraneous nonlocals --- src/handler.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/handler.py b/src/handler.py index d07a6cb..ec993ca 100644 --- a/src/handler.py +++ b/src/handler.py @@ -293,7 +293,6 @@ def connect_callback(cli): if botconfig.SASL_AUTHENTICATION: @hook("authenticate") def auth_plus(cli, something, plus): - nonlocal selected_sasl if plus == "+": if selected_sasl == "EXTERNAL": cli.send("AUTHENTICATE +") @@ -312,7 +311,7 @@ def connect_callback(cli): @hook("906") @hook("907") def on_failure_auth(cli, *etc): - nonlocal supported_sasl, selected_sasl + nonlocal selected_sasl if selected_sasl == "EXTERNAL" and (supported_sasl is None or "PLAIN" in supported_sasl): # EXTERNAL failed, retry with PLAIN as we may not have set up the client cert yet selected_sasl = "PLAIN"