From 548ddb57b08584004a07d8e07f409341e7f4c321 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 20 Jul 2016 11:41:35 +0200 Subject: [PATCH] Add TLS certificate check tests for clients. --- irctest/cases.py | 24 ++++- irctest/client_mock.py | 8 +- irctest/client_tests/test_tls.py | 143 ++++++++++++++++++++++++++++ irctest/controllers/limnoria.py | 12 ++- irctest/controllers/sopel.py | 6 +- irctest/exceptions.py | 6 ++ irctest/irc_utils/message_parser.py | 2 +- irctest/tls.py | 4 + 8 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 irctest/client_tests/test_tls.py create mode 100644 irctest/exceptions.py create mode 100644 irctest/tls.py diff --git a/irctest/cases.py b/irctest/cases.py index c050ed5..7319a70 100644 --- a/irctest/cases.py +++ b/irctest/cases.py @@ -1,5 +1,7 @@ +import ssl import time import socket +import tempfile import unittest import functools import collections @@ -11,6 +13,7 @@ from . import client_mock from . import authentication from .irc_utils import capabilities from .irc_utils import message_parser +from .exceptions import ConnectionClosed from .specifications import Specifications class _IrcTestCase(unittest.TestCase): @@ -107,9 +110,23 @@ class BaseClientTestCase(_IrcTestCase): self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server.bind(('', 0)) # Bind any free port self.server.listen(1) - def acceptClient(self): + def acceptClient(self, tls_cert=None, tls_key=None): """Make the server accept a client connection. Blocking.""" (self.conn, addr) = self.server.accept() + if tls_cert is None and tls_key is None: + pass + else: + assert tls_cert and tls_key, \ + 'tls_cert must be provided if and only if tls_key is.' + with tempfile.NamedTemporaryFile('at') as certfile, \ + tempfile.NamedTemporaryFile('at') as keyfile: + certfile.write(tls_cert) + certfile.seek(0) + keyfile.write(tls_key) + keyfile.seek(0) + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certfile=certfile.name, keyfile=keyfile.name) + self.conn = context.wrap_socket(self.conn, server_side=True) self.conn_file = self.conn.makefile(newline='\r\n', encoding='utf8') @@ -127,6 +144,8 @@ class BaseClientTestCase(_IrcTestCase): and returns this message.""" while True: line = self.getLine(*args) + if not line: + raise ConnectionClosed() msg = message_parser.parse_message(line) if not filter_pred or filter_pred(msg): return msg @@ -141,12 +160,13 @@ class BaseClientTestCase(_IrcTestCase): class ClientNegociationHelper: """Helper class for tests handling capabilities negociation.""" - def readCapLs(self, auth=None): + def readCapLs(self, auth=None, tls_config=None): (hostname, port) = self.server.getsockname() self.controller.run( hostname=hostname, port=port, auth=auth, + tls_config=tls_config, ) self.acceptClient() m = self.getMessage() diff --git a/irctest/client_mock.py b/irctest/client_mock.py index 5a4f67e..f99cc3c 100644 --- a/irctest/client_mock.py +++ b/irctest/client_mock.py @@ -2,13 +2,7 @@ import ssl import time import socket from .irc_utils import message_parser - - -class NoMessageException(AssertionError): - pass - -class ConnectionClosed(Exception): - pass +from .exceptions import NoMessageException, ConnectionClosed class ClientMock: def __init__(self, name, show_io): diff --git a/irctest/client_tests/test_tls.py b/irctest/client_tests/test_tls.py new file mode 100644 index 0000000..737fa2e --- /dev/null +++ b/irctest/client_tests/test_tls.py @@ -0,0 +1,143 @@ +from irctest import tls +from irctest import cases +from irctest.exceptions import ConnectionClosed +from irctest.irc_utils.message_parser import Message + +BAD_CERT = """ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAOd2PGU3RNwhMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwNzIwMDg0NjIwWhcNMTYwODE5MDg0NjIwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAqo1Xu+f7UmdtNPTNPLxfILf9j/kGNNHkfqVjMHXc9rNL+JoMQ3eTdy7x +BqrWmiCHNOBAeES9anF+2SAd0LiOD2gO6h8R/+s9ftNCmZJa6kCGLX1uf5rp85aD +YbqbalgQS6PtRQZHU7+XOtW/YOolpG/2omgQmZMLyEQKNseQ4VQnuIYZoJRmXLsK +eyLgWNbpz0CsLljEziTsOLYnX9n8T469+EWgFQIvWpd/jirNTSPGTc3HVRs9g7dy +fZNi7b0jjb0qhDCOR0Kvyl9I0ANz4uEX+z/ZYfsZFU4xV7vxrDNp4gSAu8bW5JQy +/jJOsGL/9pXthCsXxY0S/6PQK70DOQIDAQABo1AwTjAdBgNVHQ4EFgQUME3YXimi +RNBg6V0SWY/417o/2zIwHwYDVR0jBBgwFoAUME3YXimiRNBg6V0SWY/417o/2zIw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAPljmzqGfc4wcdkTFSSBg +BQzq/nUn16cTtRYaOOxAxCK4VFWY9MxxlcVlDUx1VtUPBJaUNqJ+xdIIdwBOH3O/ +jwDIQMRVlXwolTZvXw/xoatpb20644bltvftJ+6TpXY6z673+5Pu7b8FjNpZd/qs +5MGsgkAGkNN6hVvOqVASMqaO5vv7UgrL1Dh4R//ADBhonBwEP4Ykz+Y8gDVXlfSx +ak4YDQfuB2+M8Y3Y9PgKNZclYEacXwV/ZIxfm7vkOPlKOEeyi9+PzCEJINWnoE08 +HNsJTz9ijzsHiac6Xw07FwOBQ/3LRngfcgEOqS6W8vTC4vCkWb88mbLI4CUwi+n7 +dw== +-----END CERTIFICATE----- +""" +BAD_KEY = """ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjVe75/tSZ200 +9M08vF8gt/2P+QY00eR+pWMwddz2s0v4mgxDd5N3LvEGqtaaIIc04EB4RL1qcX7Z +IB3QuI4PaA7qHxH/6z1+00KZklrqQIYtfW5/munzloNhuptqWBBLo+1FBkdTv5c6 +1b9g6iWkb/aiaBCZkwvIRAo2x5DhVCe4hhmglGZcuwp7IuBY1unPQKwuWMTOJOw4 +tidf2fxPjr34RaAVAi9al3+OKs1NI8ZNzcdVGz2Dt3J9k2LtvSONvSqEMI5HQq/K +X0jQA3Pi4Rf7P9lh+xkVTjFXu/GsM2niBIC7xtbklDL+Mk6wYv/2le2EKxfFjRL/ +o9ArvQM5AgMBAAECggEAPwbqxDMvij1Uezx4WBiY4wN7fegeJgjm8vJ1nGQCG10Z +Fy7+lzQqV+IOClO56M1aiezRhmCIyzxUDzMyMX7yaLkgwd5njXbGjAbQVuZiGK1t +qIPxANEj4fPea5BFfOA8bWeP+HEgjM+BuKljBxKghIsnzs68S7SupvyV9bZ8UPho +uGZdgFfwzJlYTrjuZg1xz3KSsjDC/MrTQ3QldYlqMLjFooZH74j+vh/HAesEUu+E +aNMw0sAYi70F5xqAjLjEdNxKz05fGEkh1PPeohe2hF+vCDMMf/Si2PIbA44Z1Sod +0cFCE1zQhuJ1yOLQJwQ7wgEh9/Zz+M4L2BLB7P5OPQKBgQDgu1I1kqv/1EGTd36v +IQbYr1MVLzqWVXCTd7wdOcWIO538veQ/n/ED183I7xDt3GCBvXIwdoC0e4C9ZCAl +mjFUAawWDeQ0Ficbop51v1R/b/iAxQaIq1StUKrahZO0jjyH96CHISSUNlEWoRE3 +Zh9F+PQ7tz77swn+q4oTeiUcBwKBgQDCSDVBZHO5mUTeVlyA+G93l/AwRxihWnGl +5yF/ybqxrf27MywhN7fhZCvNtcYfWTbJOh6fwnzcj0YcrPQFJ2QYt9R+tSLhkXPs +X5aXHH9MQ+lItUQ0rmSv2D8MpIulwmUpZIoCKMs17Pb81EU4NSFwa2eJmdezAyHW +T9LlQReWvwKBgDqbP0YvWOGftfZCLGx5fXKWzmDw7yNzZqdei1VH0qbDfWEDGHor +OMxaxBTJm62cUiKjiBrxXIE00A8UBHop6wFQalNaDhAzUsGXOCHW4q9VQQY724da +vvtv1Q6l1S46Bbkjr95tmz93ps/y8y1yWWeDFBZapHc5arrae2i26uSTAoGACEhf +zNvleyInp3rzEqSEzAp0OPqu+CIM+k+yQ+prxStvx81Usk3XzwogO/Ll8WwyQ73w +lEsMW7LYAFz3Qkj9oXgk3QoH5Kn40Tj6CJM0ciHrDih8MerFbCHB/l39fiGdgnhA +0fq/PxtNJFZAZTcOp+ZMUbd3VLBrfuGEUjXGNa0CgYEAqtwfoXxUIPWfZ7ezNX2m +Cbnl6JGjjYoDgohr8lHcpIc+dVChLopHayUxECWIU03Todlrn2/KNwjUKtovSsty +h4WuPDAI4yh24GjaCZYGR5xcqPCy5CNjMLxdA7HsP+Gcr3eY5XS7noBrbC6IaA0j +9E+dB63zMDFOnC4UVg5rD28= +-----END PRIVATE KEY----- +""" + +GOOD_FINGERPRINT = 'E1EE6DE2DBC0D43E3B60407B5EE389AEC9D2C53178E0FB14CD51C3DFD544AA2B' +GOOD_CERT = """ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKtD9XMC1R0vMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwNzIwMDg0NjU0WhcNMTYwODE5MDg0NjU0WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA0CZDv/ny3caI0a2r7P9qH3eyPYxd+Vz5i6YzCrVIqpq9PeWL9zf9IQoM +4TAOMS9VQOCq3HsSm0YKRC9tYflmBb03rriUExsFyd4CgAqKjYNJDrTWX23j+g5T +KHF+gYhQlIljQcvX1JVMHThS1nYCz6tnbBUsKrncW9LxnR0PydL+i8jS2SkPhe/z +t/VfWsTigSzz7xVEA54ow4sYbXVx1D6CNsjccTq/hfbRGkBWvYDZt7s/bj2h445Y +B1uVuIQygySkwGQMnNALZMUhiAsuCyV7PNNleGbIPUd0LExD6OQPVchof+tdiXq7 +ndLsVv6Ufh1DhPDXtn9891sOkoj2cQIDAQABo1AwTjAdBgNVHQ4EFgQUtsTGgJ3E +rRxqF0doikKnpvDr/dswHwYDVR0jBBgwFoAUtsTGgJ3ErRxqF0doikKnpvDr/dsw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAWT2/0/ONY6XflNqGvn0i +XfB72FKIttuxMPiFKoV4czD2JWFZJ6eSTS+9NOUFPOzJfakl/F3a5Vy41hAF35o3 +9N0jQt1ixkxi/BPEW2Twst4smnYgKHS4Lke8/EPn2gemxKEz7lpwICR/bFgOFIR5 +OvQ2HQ+16yi8TsbB3QTUyVuixhYawlOpTtmDg9hho74+VA1oJ5bpx2maS2OTH35O +C458H4VAVNxtOIZF/zUhD8TEuTIElZtzJpghB9MdblaV8vs1fe2+ZWMXzSKOKj12 +nGGz249IcunUMzjOzk6w7sVSZRWkwtwov5DsyaeW2+raig+NfF7sLECI57GWakVJ +Pg== +-----END CERTIFICATE----- +""" +GOOD_KEY = """ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQJkO/+fLdxojR +ravs/2ofd7I9jF35XPmLpjMKtUiqmr095Yv3N/0hCgzhMA4xL1VA4KrcexKbRgpE +L21h+WYFvTeuuJQTGwXJ3gKACoqNg0kOtNZfbeP6DlMocX6BiFCUiWNBy9fUlUwd +OFLWdgLPq2dsFSwqudxb0vGdHQ/J0v6LyNLZKQ+F7/O39V9axOKBLPPvFUQDnijD +ixhtdXHUPoI2yNxxOr+F9tEaQFa9gNm3uz9uPaHjjlgHW5W4hDKDJKTAZAyc0Atk +xSGICy4LJXs802V4Zsg9R3QsTEPo5A9VyGh/612Jerud0uxW/pR+HUOE8Ne2f3z3 +Ww6SiPZxAgMBAAECggEAMeb6lyv1bfYLFznr3gXeC21G7jqYzQ/dQ/20fvy3Ty+J +7yz5QWvK5ADk1ZgPzvrqFYPHctSOwWspSu+T6clBDF8w2lKmLW5tFNiFAO2GCidP +fJceTgKqhWipxyhui9+Cchn+Eegs9mpUtSyrr37bba5KPT9WN2gXzGvmQSSWhGwC +ewrJjh+HBpS3B006kqD9kOdgJYlmTayOaqD9WOH/ppFY8zNopEx27qqEph6WOA7W +84oUqm4JNabyXz7tRa/YsBUUqKmlBgxp0sKu5NttBPUi1+LpumjWIRj6M85NHliK +lCJgEkNriXMQ7joS0n0ZK+1cuKNr7hiGKwDoNUsfgQKBgQD0uB8KAgIjkgA+FRpr +cIQ35INPdkN8HkzZdg19nrQTwuh2Jo4SSbfm57KwyZBMnP2kHP6CMZNCycVXXj+r +1jo5ZFdAyz+MgUdTmTBXV/wH1YS6Cbp4KOv64eYT3/3cGUE5aBG0U5CqNaYq0XT7 +CxPiF+pDRVDDQk+rBFz9gCKeWwKBgQDZvpd2DkVWvL0sk6pu9taPutKN145USzOn +j3SceLzL8Lu5nEfkW9O9EG0APVf8M7iq4JYF5Yzam94/pbLpSZHzYuEgNt1ee7TG +7cnxsKxQ9PDrvbElJGx0j0XRwG/CU3RJeDDmaUyosKjcYMhJujDk05Vm3RTLsii2 +QiCvu4jwIwKBgQCXbl/2p2t/a1cvE4v3s/Z9R7BhuYLlCTLw1fZfJ5ezKscCZbVA +Z9Ge1v1iHDho0DS8Gxz6n4bKq2SsPawUv0nkPc0oUR0P6ueiOYcKZW2Vw3CQVnjG +5juwUZ0360GBszcDOPzLo3I/gVdD470Jo784Byh1XC0vxpbZ8qdATswdRQKBgDad +XXQZBD9LO8/QgfEvLIYEgAdfx61Q53Xhv4f3qLMmgI9/qXCXr7Y+RnjG6iix+GGz +zy1PdFLowYgJUaS99UOsy3a/DCtEsAUtY3ehrrbnmP4oKCR+zE04GnUP5XhCYmqD +IRDJ3JZ7KP+Nru7/KoBaqaCRV0P4PcnpMDWjvictAoGAWTFD2h/tsSWyHN2OyyBG +wmfusGVYB23RgQzXiLdlZOwWHZGON9dKEc9Pq6ddRArO01ewAKkcfieaLLpgb67C +Sw3oB/NsbUMkKze1zwXs9e2vcPt42vnRuQ75jU7Pb9p2NHpAdA4K/3CV00QzGA+e +El9iqRlAhgqaXc4Iz/Zxxhs= +-----END PRIVATE KEY----- +""" + +class TlsTestCase(cases.BaseClientTestCase): + def testTrustedCertificate(self): + tls_config = tls.TlsConfig( + enable=True, + trusted_fingerprints=[GOOD_FINGERPRINT]) + (hostname, port) = self.server.getsockname() + self.controller.run( + hostname=hostname, + port=port, + auth=None, + tls_config=tls_config, + ) + self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY) + m = self.getMessage() + + def testUntrustedCertificate(self): + tls_config = tls.TlsConfig( + enable=True, + trusted_fingerprints=[GOOD_FINGERPRINT]) + (hostname, port) = self.server.getsockname() + self.controller.run( + hostname=hostname, + port=port, + auth=None, + tls_config=tls_config, + ) + self.acceptClient(tls_cert=BAD_CERT, tls_key=BAD_KEY) + with self.assertRaises(ConnectionClosed): + m = self.getMessage() diff --git a/irctest/controllers/limnoria.py b/irctest/controllers/limnoria.py index 3925c78..21b106f 100644 --- a/irctest/controllers/limnoria.py +++ b/irctest/controllers/limnoria.py @@ -2,6 +2,7 @@ import os import subprocess from irctest import authentication +from irctest.basecontrollers import NotImplementedByController from irctest.basecontrollers import BaseClientController, DirectoryBasedController TEMPLATE_CONFIG = """ @@ -9,9 +10,14 @@ supybot.directories.conf: {directory}/conf supybot.directories.data: {directory}/data supybot.directories.migrations: {directory}/migrations supybot.log.stdout.level: {loglevel} + supybot.networks: testnet supybot.networks.testnet.servers: {hostname}:{port} -supybot.networks.testnet.ssl: False + +supybot.protocols.ssl.verifyCertificates: True +supybot.networks.testnet.ssl: {enable_tls} +supybot.networks.testnet.ssl.serverFingerprints: {trusted_fingerprints} + supybot.networks.testnet.sasl.username: {username} supybot.networks.testnet.sasl.password: {password} supybot.networks.testnet.sasl.ecdsa_key: {directory}/ecdsa_key.pem @@ -30,7 +36,7 @@ class LimnoriaController(BaseClientController, DirectoryBasedController): with self.open_file('conf/users.conf'): pass - def run(self, hostname, port, auth): + def run(self, hostname, port, auth, tls_config): # Runs a client with the config given as arguments assert self.proc is None self.create_config() @@ -51,6 +57,8 @@ class LimnoriaController(BaseClientController, DirectoryBasedController): username=auth.username if auth else '', password=auth.password if auth else '', mechanisms=mechanisms.lower(), + enable_tls=tls_config.enable if tls_config else 'False', + trusted_fingerprints=' '.join(tls_config.trusted_fingerprints) if tls_config else '', )) self.proc = subprocess.Popen(['supybot', os.path.join(self.directory, 'bot.conf')]) diff --git a/irctest/controllers/sopel.py b/irctest/controllers/sopel.py index b464994..9e7b158 100644 --- a/irctest/controllers/sopel.py +++ b/irctest/controllers/sopel.py @@ -3,6 +3,7 @@ import tempfile import subprocess from irctest.basecontrollers import BaseClientController +from irctest.basecontrollers import NotImplementedByController TEMPLATE_CONFIG = """ [core] @@ -45,8 +46,11 @@ class SopelController(BaseClientController): with self.open_file(self.filename) as fd: pass - def run(self, hostname, port, auth): + def run(self, hostname, port, auth, tls_config): # Runs a client with the config given as arguments + if tls_config is not None: + raise NotImplementedByController( + 'TLS configuration') assert self.proc is None self.create_config() with self.open_file(self.filename) as fd: diff --git a/irctest/exceptions.py b/irctest/exceptions.py new file mode 100644 index 0000000..8b1ca49 --- /dev/null +++ b/irctest/exceptions.py @@ -0,0 +1,6 @@ +class NoMessageException(AssertionError): + pass + +class ConnectionClosed(Exception): + pass + diff --git a/irctest/irc_utils/message_parser.py b/irctest/irc_utils/message_parser.py index bda001e..9508933 100644 --- a/irctest/irc_utils/message_parser.py +++ b/irctest/irc_utils/message_parser.py @@ -36,7 +36,7 @@ def parse_message(s): http://tools.ietf.org/html/rfc1459#section-2.3.1 and http://ircv3.net/specs/core/message-tags-3.2.html""" - assert s.endswith('\r\n'), 'Message does not end with CR LF' + assert s.endswith('\r\n'), 'Message does not end with CR LF: {!r}'.format(s) s = s[0:-2] if s.startswith('@'): (tags, s) = s.split(' ', 1) diff --git a/irctest/tls.py b/irctest/tls.py new file mode 100644 index 0000000..acb9b74 --- /dev/null +++ b/irctest/tls.py @@ -0,0 +1,4 @@ +import collections + +TlsConfig = collections.namedtuple('TlsConfig', + 'enable trusted_fingerprints')