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}"