From 03dfcf3e791b8af8806d8ce40701eff5582837af Mon Sep 17 00:00:00 2001 From: JustAnotherArchivist Date: Sun, 15 Dec 2019 15:37:07 +0000 Subject: [PATCH] Add CertFP support --- config.example.toml | 3 +++ http2irc.py | 55 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/config.example.toml b/config.example.toml index f2f343d..7c2ff18 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,6 +5,9 @@ #ssl = 'yes' #nick = 'h2ibot' #real = 'I am an http2irc bot.' + # Certificate and key for CertFP authentication with NickServ; certfile is a string containing the path to a .pem file which has the certificate and the key, certkeyfile similarly for one containing only the key; default values are empty (None in Python) to disable CertFP authentication + #certfile = + #certkeyfile = [web] #host = '127.0.0.1' diff --git a/http2irc.py b/http2irc.py index 3382c77..eec334a 100644 --- a/http2irc.py +++ b/http2irc.py @@ -5,6 +5,7 @@ import base64 import collections import concurrent.futures import logging +import os.path import signal import ssl import sys @@ -27,6 +28,29 @@ def _mapping_to_namespace(d): return types.SimpleNamespace(**{key: _mapping_to_namespace(value) if isinstance(value, collections.abc.Mapping) else value for key, value in d.items()}) +def is_valid_pem(path, withCert): + '''Very basic check whether something looks like a valid PEM certificate''' + try: + with open(path, 'rb') as fp: + contents = fp.read() + + # All of these raise exceptions if something's wrong... + if withCert: + assert contents.startswith(b'-----BEGIN CERTIFICATE-----\n') + endCertPos = contents.index(b'-----END CERTIFICATE-----\n') + base64.b64decode(contents[28:endCertPos].replace(b'\n', b''), validate = True) + assert contents[endCertPos + 26:].startswith(b'-----BEGIN PRIVATE KEY-----\n') + else: + assert contents.startswith(b'-----BEGIN PRIVATE KEY-----\n') + endCertPos = -26 # Please shoot me. + endKeyPos = contents.index(b'-----END PRIVATE KEY-----\n') + base64.b64decode(contents[endCertPos + 26 + 28: endKeyPos].replace(b'\n', b''), validate = True) + assert contents[endKeyPos + 26:] == b'' + return True + except: # Yes, really + return False + + class Config: def __init__(self, filename): self._filename = filename @@ -46,7 +70,7 @@ class Config: if any(not isinstance(x, collections.abc.Mapping) for x in obj.values()): raise InvalidConfig('Invalid section type(s), expected objects/dicts') if 'irc' in obj: - if any(x not in ('host', 'port', 'ssl', 'nick', 'real') for x in obj['irc']): + if any(x not in ('host', 'port', 'ssl', 'nick', 'real', 'certfile', 'certkeyfile') for x in obj['irc']): raise InvalidConfig('Unknown key found in irc section') if 'host' in obj['irc'] and not isinstance(obj['irc']['host'], str): #TODO: Check whether it's a valid hostname raise InvalidConfig('Invalid IRC host') @@ -58,6 +82,22 @@ class Config: raise InvalidConfig('Invalid IRC nick') if 'real' in obj['irc'] and not isinstance(obj['irc']['real'], str): raise InvalidConfig('Invalid IRC realname') + if ('certfile' in obj['irc']) != ('certkeyfile' in obj['irc']): + raise InvalidConfig('Invalid IRC cert config: needs both certfile and certkeyfile') + if 'certfile' in obj['irc']: + if not isinstance(obj['irc']['certfile'], str): + raise InvalidConfig('Invalid certificate file: not a string') + if not os.path.isfile(obj['irc']['certfile']): + raise InvalidConfig('Invalid certificate file: not a regular file') + if not is_valid_pem(obj['irc']['certfile'], True): + raise InvalidConfig('Invalid certificate file: not a valid PEM cert') + if 'certkeyfile' in obj['irc']: + if not isinstance(obj['irc']['certkeyfile'], str): + raise InvalidConfig('Invalid certificate key file: not a string') + if not os.path.isfile(obj['irc']['certkeyfile']): + raise InvalidConfig('Invalid certificate key file: not a regular file') + if not is_valid_pem(obj['irc']['certkeyfile'], False): + raise InvalidConfig('Invalid certificate key file: not a valid PEM key') if 'web' in obj: if any(x not in ('host', 'port') for x in obj['web']): raise InvalidConfig('Unknown key found in web section') @@ -78,7 +118,7 @@ class Config: #TODO: Check values # Default values - self._obj = {'irc': {'host': 'irc.hackint.org', 'port': 6697, 'ssl': 'yes', 'nick': 'h2ibot', 'real': 'I am an http2irc bot.'}, 'web': {'host': '127.0.0.1', 'port': 8080}, 'maps': {}} + self._obj = {'irc': {'host': 'irc.hackint.org', 'port': 6697, 'ssl': 'yes', 'nick': 'h2ibot', 'real': 'I am an http2irc bot.', 'certfile': None, 'certkeyfile': None}, 'web': {'host': '127.0.0.1', 'port': 8080}, 'maps': {}} # Fill in default values for the maps for key, map_ in obj['maps'].items(): @@ -293,12 +333,21 @@ class IRCClient: self.channels = {map_.ircchannel for map_ in config.maps.__dict__.values()} self._protocol.update_channels(self.channels) + def _get_ssl_context(self): + ctx = SSL_CONTEXTS[self.config.irc.ssl] + if self.config.irc.certfile and self.config.irc.certkeyfile: + if ctx is True: + ctx = ssl.create_default_context() + if isinstance(ctx, ssl.SSLContext): + ctx.load_cert_chain(self.config.irc.certfile, keyfile = self.config.irc.certkeyfile) + return ctx + async def run(self, loop, sigintEvent): connectionClosedEvent = asyncio.Event() while True: connectionClosedEvent.clear() try: - self._transport, self._protocol = await loop.create_connection(lambda: IRCClientProtocol(self.messageQueue, connectionClosedEvent, loop, self.config, self.channels), self.config.irc.host, self.config.irc.port, ssl = SSL_CONTEXTS[self.config.irc.ssl]) + self._transport, self._protocol = await loop.create_connection(lambda: IRCClientProtocol(self.messageQueue, connectionClosedEvent, loop, self.config, self.channels), self.config.irc.host, self.config.irc.port, ssl = self._get_ssl_context()) try: await asyncio.wait((connectionClosedEvent.wait(), sigintEvent.wait()), return_when = concurrent.futures.FIRST_COMPLETED) finally: