Merge pull request #313 from lykoss/tls_validation

Support for TLS certificate verification and client certificates
This commit is contained in:
Em Barry 2018-01-10 15:15:12 -05:00 committed by GitHub
commit f7a2ad5cc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 154 additions and 16 deletions

6
.gitignore vendored
View File

@ -8,6 +8,9 @@
__pycache__/
*.py[co]
# Linter files
.mypy_cache/
# Config files
botconfig.py
/gamemodes.py
@ -20,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

@ -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)
@ -145,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)
@ -174,7 +182,69 @@ 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 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.
try:
# 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="error")
raise
try:
self.socket = ctx.wrap_socket(self.socket, server_hostname=self.host)
except Exception as error:
self.stream_handler("Could not connect with TLS: {0}".format(error), level="error")
raise
if self.cert_fp:
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()
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")
if not self.blocking:
self.socket.setblocking(0)
@ -186,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)
@ -298,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 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))
cli.quit()
raise ValueError("Server does not support the SASL {0} mechanism".format(mech))
else:
cli.send("CAP END")
elif cmd == "NAK":
@ -273,10 +294,13 @@ def connect_callback(cli):
@hook("authenticate")
def auth_plus(cli, something, plus):
if plus == "+":
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)
cli.send("AUTHENTICATE " + auth_token, log="AUTHENTICATE [redacted]")
@hook("903")
def on_successful_auth(cli, blah, blahh, blahhh):
@ -287,6 +311,13 @@ def connect_callback(cli):
@hook("906")
@hook("907")
def on_failure_auth(cli, *etc):
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"
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()

View File

@ -6,6 +6,7 @@ from collections import defaultdict, OrderedDict
import botconfig
LANGUAGE = 'en'
MINIMUM_WAIT = 60
EXTRA_WAIT = 30
EXTRA_WAIT_JOIN = 0 # Add this many seconds to the waiting time for each !join
@ -192,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}"

View File

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