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.
This commit is contained in:
skizzerz 2018-01-10 12:03:05 -07:00
parent 532386a2b9
commit 9190a4c859
5 changed files with 93 additions and 94 deletions

3
.gitignore vendored
View File

@ -23,3 +23,6 @@ botconfig.py
# Log files
*.log
# Certificates/keys
*.pem

View File

@ -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.

View File

@ -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:

View File

@ -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)

View File

@ -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. (<https://wiki.openssl.org/index.php/Manual:Ciphers(1)#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}"