Merge pull request #313 from lykoss/tls_validation
Support for TLS certificate verification and client certificates
This commit is contained in:
commit
f7a2ad5cc7
6
.gitignore
vendored
6
.gitignore
vendored
@ -8,6 +8,9 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[co]
|
*.py[co]
|
||||||
|
|
||||||
|
# Linter files
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
# Config files
|
# Config files
|
||||||
botconfig.py
|
botconfig.py
|
||||||
/gamemodes.py
|
/gamemodes.py
|
||||||
@ -20,3 +23,6 @@ botconfig.py
|
|||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Certificates/keys
|
||||||
|
*.pem
|
||||||
|
@ -1,13 +1,29 @@
|
|||||||
HOST = "chat.freenode.net"
|
HOST = "chat.freenode.net"
|
||||||
PORT = 6697
|
PORT = 6697
|
||||||
USE_SSL = True
|
|
||||||
NICK = "mywolfbot"
|
NICK = "mywolfbot"
|
||||||
IDENT = NICK
|
IDENT = NICK
|
||||||
REALNAME = NICK
|
REALNAME = NICK
|
||||||
USERNAME = "" # For authentication; can be left blank if the same as 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
|
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"
|
CHANNEL = "##mywolfgame"
|
||||||
|
|
||||||
CMD_CHAR = "!"
|
CMD_CHAR = "!"
|
||||||
@ -18,7 +34,6 @@ CMD_CHAR = "!"
|
|||||||
#
|
#
|
||||||
# Note: Do not put the account and password here; they will be automatically substituted
|
# 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.
|
# from the USERNAME (or NICK) and PASS variables on the top of the file.
|
||||||
|
|
||||||
SERVER_PASS = None
|
SERVER_PASS = None
|
||||||
|
|
||||||
OWNERS = ("unaffiliated/wolfbot_admin1",) # The comma is required at the end if there is only one owner.
|
OWNERS = ("unaffiliated/wolfbot_admin1",) # The comma is required at the end if there is only one owner.
|
||||||
|
@ -22,6 +22,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import os
|
import os
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
from oyoyo.parse import parse_raw_irc_command
|
from oyoyo.parse import parse_raw_irc_command
|
||||||
|
|
||||||
@ -98,6 +100,11 @@ class IRCClient:
|
|||||||
self.blocking = True
|
self.blocking = True
|
||||||
self.sasl_auth = False
|
self.sasl_auth = False
|
||||||
self.use_ssl = 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.server_pass = None
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.stream_handler = lambda output, level=None: print(output)
|
self.stream_handler = lambda output, level=None: print(output)
|
||||||
@ -145,7 +152,8 @@ class IRCClient:
|
|||||||
for arg in args]), i))
|
for arg in args]), i))
|
||||||
|
|
||||||
msg = bytes(" ", "utf_8").join(bargs)
|
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):
|
while not self.tokenbucket.consume(1):
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
@ -174,7 +182,69 @@ class IRCClient:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if self.use_ssl:
|
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:
|
if not self.blocking:
|
||||||
self.socket.setblocking(0)
|
self.socket.setblocking(0)
|
||||||
@ -186,10 +256,10 @@ class IRCClient:
|
|||||||
message = "PASS :{0}".format(self.server_pass).format(
|
message = "PASS :{0}".format(self.server_pass).format(
|
||||||
account=self.authname if self.authname else self.nickname,
|
account=self.authname if self.authname else self.nickname,
|
||||||
password=self.password)
|
password=self.password)
|
||||||
self.send(message)
|
self.send(message, log="PASS :[redacted]")
|
||||||
elif self.server_pass:
|
elif self.server_pass:
|
||||||
message = "PASS :{0}".format(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.send("NICK", self.nickname)
|
||||||
self.user(self.ident, self.real_name)
|
self.user(self.ident, self.real_name)
|
||||||
@ -298,3 +368,5 @@ class IRCClient:
|
|||||||
if not next(conn):
|
if not next(conn):
|
||||||
self.stream_handler("Calling sys.exit()...", level="warning")
|
self.stream_handler("Calling sys.exit()...", level="warning")
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
|
# vim: set sw=4 expandtab:
|
||||||
|
@ -238,13 +238,23 @@ def connect_callback(cli):
|
|||||||
request_caps.add("sasl")
|
request_caps.add("sasl")
|
||||||
|
|
||||||
supported_caps = set()
|
supported_caps = set()
|
||||||
|
supported_sasl = None
|
||||||
|
selected_sasl = None
|
||||||
|
|
||||||
@hook("cap")
|
@hook("cap")
|
||||||
def on_cap(cli, svr, mynick, cmd, *caps):
|
def on_cap(cli, svr, mynick, cmd, *caps):
|
||||||
|
nonlocal supported_sasl, selected_sasl
|
||||||
# caps is a star because we might receive multiline in LS
|
# caps is a star because we might receive multiline in LS
|
||||||
if cmd == "LS":
|
if cmd == "LS":
|
||||||
for item in caps[-1].split(): # First item may or may not be *, for multiline
|
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
|
if caps[0] == "*": # Multiline, don't continue yet
|
||||||
return
|
return
|
||||||
@ -261,7 +271,18 @@ def connect_callback(cli):
|
|||||||
|
|
||||||
elif cmd == "ACK":
|
elif cmd == "ACK":
|
||||||
if "sasl" in caps[0]:
|
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:
|
else:
|
||||||
cli.send("CAP END")
|
cli.send("CAP END")
|
||||||
elif cmd == "NAK":
|
elif cmd == "NAK":
|
||||||
@ -273,10 +294,13 @@ def connect_callback(cli):
|
|||||||
@hook("authenticate")
|
@hook("authenticate")
|
||||||
def auth_plus(cli, something, plus):
|
def auth_plus(cli, something, plus):
|
||||||
if plus == "+":
|
if plus == "+":
|
||||||
account = (botconfig.USERNAME or botconfig.NICK).encode("utf-8")
|
if selected_sasl == "EXTERNAL":
|
||||||
password = botconfig.PASS.encode("utf-8")
|
cli.send("AUTHENTICATE +")
|
||||||
auth_token = base64.b64encode(b"\0".join((account, account, password))).decode("utf-8")
|
elif selected_sasl == "PLAIN":
|
||||||
cli.send("AUTHENTICATE " + auth_token)
|
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")
|
@hook("903")
|
||||||
def on_successful_auth(cli, blah, blahh, blahhh):
|
def on_successful_auth(cli, blah, blahh, blahhh):
|
||||||
@ -287,9 +311,16 @@ def connect_callback(cli):
|
|||||||
@hook("906")
|
@hook("906")
|
||||||
@hook("907")
|
@hook("907")
|
||||||
def on_failure_auth(cli, *etc):
|
def on_failure_auth(cli, *etc):
|
||||||
alog("Authentication failed. Did you fill the account name "
|
nonlocal selected_sasl
|
||||||
"in botconfig.USERNAME if it's different from the bot nick?")
|
if selected_sasl == "EXTERNAL" and (supported_sasl is None or "PLAIN" in supported_sasl):
|
||||||
cli.quit()
|
# 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)
|
users.Bot = users.BotUser(cli, botconfig.NICK)
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ from collections import defaultdict, OrderedDict
|
|||||||
import botconfig
|
import botconfig
|
||||||
|
|
||||||
LANGUAGE = 'en'
|
LANGUAGE = 'en'
|
||||||
|
|
||||||
MINIMUM_WAIT = 60
|
MINIMUM_WAIT = 60
|
||||||
EXTRA_WAIT = 30
|
EXTRA_WAIT = 30
|
||||||
EXTRA_WAIT_JOIN = 0 # Add this many seconds to the waiting time for each !join
|
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
|
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
|
# 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 = "NickServ"
|
||||||
NICKSERV_IDENTIFY_COMMAND = "IDENTIFY {account} {password}"
|
NICKSERV_IDENTIFY_COMMAND = "IDENTIFY {account} {password}"
|
||||||
NICKSERV_GHOST_COMMAND = "GHOST {nick}"
|
NICKSERV_GHOST_COMMAND = "GHOST {nick}"
|
||||||
|
@ -51,6 +51,7 @@ from oyoyo.client import IRCClient
|
|||||||
import src
|
import src
|
||||||
from src import handler
|
from src import handler
|
||||||
from src.events import Event
|
from src.events import Event
|
||||||
|
import src.settings as var
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
evt = Event("init", {})
|
evt = Event("init", {})
|
||||||
@ -70,6 +71,11 @@ def main():
|
|||||||
sasl_auth=botconfig.SASL_AUTHENTICATION,
|
sasl_auth=botconfig.SASL_AUTHENTICATION,
|
||||||
server_pass=botconfig.SERVER_PASS,
|
server_pass=botconfig.SERVER_PASS,
|
||||||
use_ssl=botconfig.USE_SSL,
|
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,
|
connect_cb=handler.connect_callback,
|
||||||
stream_handler=src.stream,
|
stream_handler=src.stream,
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user