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:
parent
532386a2b9
commit
9190a4c859
3
.gitignore
vendored
3
.gitignore
vendored
@ -23,3 +23,6 @@ botconfig.py
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Certificates/keys
|
||||
*.pem
|
||||
|
@ -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.
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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}"
|
||||
|
Loading…
x
Reference in New Issue
Block a user