banned/oyoyo/client.py
skizzerz 9190a4c859 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.
2018-01-10 12:09:10 -07:00

373 lines
16 KiB
Python

# Copyright (c) 2011 Duncan Fordyce, Jimmy Cao
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import socket
import ssl
import sys
import threading
import time
import traceback
import os
import hashlib
import hmac
from oyoyo.parse import parse_raw_irc_command
# Adapted from http://code.activestate.com/recipes/511490-implementation-of-the-token-bucket-algorithm/
class TokenBucket(object):
"""An implementation of the token bucket algorithm.
>>> bucket = TokenBucket(80, 0.5)
>>> bucket.consume(1)
"""
def __init__(self, tokens, fill_rate):
"""tokens is the total tokens in the bucket. fill_rate is the
rate in tokens/second that the bucket will be refilled."""
self.capacity = float(tokens)
self._tokens = float(tokens)
self.fill_rate = float(fill_rate)
self.timestamp = time.time()
def consume(self, tokens):
"""Consume tokens from the bucket. Returns True if there were
sufficient tokens otherwise False."""
if tokens <= self.tokens:
self._tokens -= tokens
return True
return False
@property
def tokens(self):
now = time.time()
if self._tokens < self.capacity:
delta = self.fill_rate * (now - self.timestamp)
self._tokens = min(self.capacity, self._tokens + delta)
self.timestamp = now
return self._tokens
def __repr__(self):
return "{self.__class__.__name__}(capacity={self.capacity}, fill rate={self.fill_rate}, tokens={self.tokens})".format(self=self)
class IRCClient:
""" IRC Client class. This handles one connection to a server.
This can be used either with or without IRCApp ( see connect() docs )
"""
def __init__(self, cmd_handler, **kwargs):
""" the first argument should be an object with attributes/methods named
as the irc commands. You may subclass from one of the classes in
oyoyo.cmdhandler for convenience but it is not required. The
methods should have arguments (prefix, args). prefix is
normally the sender of the command. args is a list of arguments.
Its recommened you subclass oyoyo.cmdhandler.DefaultCommandHandler,
this class provides defaults for callbacks that are required for
normal IRC operation.
all other arguments should be keyword arguments. The most commonly
used will be nick, host and port. You can also specify an "on connect"
callback. ( check the source for others )
Warning: By default this class will not block on socket operations, this
means if you use a plain while loop your app will consume 100% cpu.
To enable blocking pass blocking=True.
"""
self.socket = None
self.nickname = ""
self.hostmask = ""
self.ident = ""
self.real_name = ""
self.host = None
self.port = None
self.password = ""
self.authname = ""
self.connect_cb = None
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)
self.tokenbucket = TokenBucket(23, 1.73)
self.__dict__.update(kwargs)
self.command_handler = cmd_handler
self._end = 0
def __enter__(self):
return self
def __exit__(self, exc, value, tb):
return False # TODO: make this into a proper context manager
def send(self, *args, **kwargs):
""" send a message to the connected server. all arguments are joined
with a space for convenience, for example the following are identical
>>> cli.send("JOIN " + some_room)
>>> cli.send("JOIN", some_room)
In python 2, all args must be of type str or unicode, *BUT* if they are
unicode they will be converted to str with the encoding specified by
the 'encoding' keyword argument (default 'utf8').
In python 3, all args must be of type str or bytes, *BUT* if they are
str they will be converted to bytes with the encoding specified by the
'encoding' keyword argument (default 'utf8').
"""
with self.lock:
# Convert all args to bytes if not already
encoding = kwargs.get('encoding') or 'utf_8'
bargs = []
for i,arg in enumerate(args):
if isinstance(arg, str):
bargs.append(bytes(arg, encoding))
elif isinstance(arg, bytes):
bargs.append(arg)
elif arg is None:
continue
else:
raise Exception(('Refusing to send arg at index {1} of the args from '+
'provided: {0}').format(repr([(type(arg), arg)
for arg in args]), i))
msg = bytes(" ", "utf_8").join(bargs)
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)
self.socket.send(msg + bytes("\r\n", "utf_8"))
def connect(self):
""" initiates the connection to the server set in self.host:self.port
and returns a generator object.
>>> cli = IRCClient(my_handler, host="irc.freenode.net", port=6667)
>>> g = cli.connect()
>>> while 1:
... next(g)
"""
try:
retries = 0
while True:
try:
self.socket = socket.create_connection(("{0}".format(self.host), self.port))
break
except socket.error as e:
retries += 1
self.stream_handler('Error: {0}'.format(e), level="warning")
if retries > 3:
sys.exit(1)
if self.use_ssl:
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)
self.send("CAP LS 302")
if (self.server_pass and "{password}" in self.server_pass
and self.password and not self.sasl_auth):
message = "PASS :{0}".format(self.server_pass).format(
account=self.authname if self.authname else self.nickname,
password=self.password)
self.send(message, log="PASS :[redacted]")
elif self.server_pass:
message = "PASS :{0}".format(self.server_pass)
self.send(message, log="PASS :[redacted]")
self.send("NICK", self.nickname)
self.user(self.ident, self.real_name)
if self.connect_cb:
try:
self.connect_cb(self)
except Exception as e:
sys.stderr.write(traceback.format_exc())
raise e
buffer = bytes()
while not self._end:
try:
buffer += self.socket.recv(1024)
except socket.error as e:
if False and not self.blocking and e.errno == 11:
pass
else:
sys.stderr.write(traceback.format_exc())
raise e
else:
data = buffer.split(bytes("\n", "utf_8"))
buffer = data.pop()
for el in data:
prefix, command, args = parse_raw_irc_command(el)
try:
enc = "utf8"
fargs = [arg.decode(enc) for arg in args if isinstance(arg,bytes)]
except UnicodeDecodeError:
enc = "latin1"
fargs = [arg.decode(enc) for arg in args if isinstance(arg,bytes)]
try:
largs = list(args)
if prefix is not None:
prefix = prefix.decode(enc)
self.stream_handler("<--- receive {0} {1} ({2})".format(prefix, command, ", ".join(fargs)), level="debug")
# for i,arg in enumerate(largs):
# if arg is not None: largs[i] = arg.decode(enc)
if command in self.command_handler:
self.command_handler[command](self, prefix,*fargs)
elif "" in self.command_handler:
self.command_handler[""](self, prefix, command, *fargs)
except Exception as e:
sys.stderr.write(traceback.format_exc())
raise e # ?
yield True
finally:
if self.socket:
self.stream_handler('closing socket')
self.socket.close()
yield False
def msg(self, user, msg):
for line in msg.split('\n'):
maxchars = 494 - len(self.nickname+self.ident+self.hostmask+user)
while line:
extra = ""
if len(line) > maxchars:
extra = line[maxchars:]
line = line[:maxchars]
self.send("PRIVMSG", user, ":{0}".format(line))
line = extra
privmsg = msg # Same thing
def notice(self, user, msg):
for line in msg.split('\n'):
maxchars = 495 - len(self.nickname+self.ident+self.hostmask+user)
while line:
extra = ""
if len(line) > maxchars:
extra = line[maxchars:]
line = line[:maxchars]
self.send("NOTICE", user, ":{0}".format(line))
line = extra
def join(self, channel):
self.send("JOIN {0}".format(channel))
def quit(self, msg=""):
self.send("QUIT :{0}".format(msg))
def part(self, chan, msg=""):
self.send("PART {0} :{1}".format(chan, msg))
def mode(self, *args):
self.send("MODE {0}".format(" ".join(args)))
def kick(self, chan, nick, msg=""):
self.send("KICK", chan, nick, ":"+msg)
def who(self, *args):
self.send("WHO {0}".format(" ".join(args)))
def ns_identify(self, account, passwd, nickserv, command):
if command:
self.msg(nickserv, command.format(account=account, password=passwd))
def ns_ghost(self, nick, password, nickserv, command):
if command:
self.msg(nickserv, command.format(nick=nick, password=password))
def ns_release(self, nick, password, nickserv="NickServ", command="RELEASE {nick}"):
if command:
self.msg(nickserv, command.format(nick=nick, password=password))
def ns_regain(self, nick, password, nickserv="NickServ", command="REGAIN {nick}"):
if command:
self.msg(nickserv, command.format(nick=nick, password=password))
def user(self, ident, rname):
self.send("USER", ident, self.host, self.host, ":{0}".format(rname or ident))
def mainLoop(self):
conn = self.connect()
while True:
if not next(conn):
self.stream_handler("Calling sys.exit()...", level="warning")
sys.exit()
# vim: set sw=4 expandtab: