Merge branch 'cherry-picking' ('Cherry pick commits from my old branch of irctest' GH-17)

This commit is contained in:
2021-02-22 18:44:17 +01:00
14 changed files with 221 additions and 18 deletions

View File

@ -39,6 +39,11 @@ class DirectoryBasedController(_BaseController):
self.kill_proc() self.kill_proc()
if self.directory: if self.directory:
shutil.rmtree(self.directory) shutil.rmtree(self.directory)
def terminate(self):
"""Stops the process gracefully, and does not clean its config."""
self.proc.terminate()
self.proc.wait()
self.proc = None
def open_file(self, name, mode='a'): def open_file(self, name, mode='a'):
"""Open a file in the configuration directory.""" """Open a file in the configuration directory."""
assert self.directory assert self.directory
@ -49,7 +54,13 @@ class DirectoryBasedController(_BaseController):
assert os.path.isdir(dir_) assert os.path.isdir(dir_)
return open(os.path.join(self.directory, name), mode) return open(os.path.join(self.directory, name), mode)
def create_config(self): def create_config(self):
"""If there is no config dir, creates it and returns True.
Else returns False."""
if self.directory:
return False
else:
self.directory = tempfile.mkdtemp() self.directory = tempfile.mkdtemp()
return True
def gen_ssl(self): def gen_ssl(self):
self.csr_path = os.path.join(self.directory, 'ssl.csr') self.csr_path = os.path.join(self.directory, 'ssl.csr')

View File

@ -118,8 +118,9 @@ class BaseClientTestCase(_IrcTestCase):
try: try:
self.conn.sendall(b'QUIT :end of test.') self.conn.sendall(b'QUIT :end of test.')
except BrokenPipeError: except BrokenPipeError:
# client already disconnected pass # client already disconnected
pass except OSError:
pass # the conn was already closed by the test, or something
self.controller.kill() self.controller.kill()
if self.conn: if self.conn:
self.conn_file.close() self.conn_file.close()
@ -131,9 +132,10 @@ class BaseClientTestCase(_IrcTestCase):
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.bind(('', 0)) # Bind any free port self.server.bind(('', 0)) # Bind any free port
self.server.listen(1) self.server.listen(1)
def acceptClient(self, tls_cert=None, tls_key=None): def acceptClient(self, tls_cert=None, tls_key=None, server=None):
"""Make the server accept a client connection. Blocking.""" """Make the server accept a client connection. Blocking."""
(self.conn, addr) = self.server.accept() server = server or self.server
(self.conn, addr) = server.accept()
if tls_cert is None and tls_key is None: if tls_cert is None and tls_key is None:
pass pass
else: else:
@ -171,11 +173,9 @@ class BaseClientTestCase(_IrcTestCase):
if not filter_pred or filter_pred(msg): if not filter_pred or filter_pred(msg):
return msg return msg
def sendLine(self, line): def sendLine(self, line):
ret = self.conn.sendall(line.encode()) self.conn.sendall(line.encode())
assert ret is None
if not line.endswith('\r\n'): if not line.endswith('\r\n'):
ret = self.conn.sendall(b'\r\n') self.conn.sendall(b'\r\n')
assert ret is None
if self.show_io: if self.show_io:
print('{:.3f} S: {}'.format(time.time(), line.strip())) print('{:.3f} S: {}'.format(time.time(), line.strip()))
@ -455,6 +455,20 @@ class OptionalityHelper:
return f(self) return f(self)
return newf return newf
def checkCapabilitySupport(self, cap):
if cap in self.controller.supported_capabilities:
return
raise runner.CapabilityNotSupported(cap)
def skipUnlessSupportsCapability(cap):
def decorator(f):
@functools.wraps(f)
def newf(self):
self.checkCapabilitySupport(cap)
return f(self)
return newf
return decorator
class SpecificationSelector: class SpecificationSelector:
def requiredBySpecification(*specifications, strict=False): def requiredBySpecification(*specifications, strict=False):

View File

@ -1,4 +1,5 @@
import ssl import ssl
import sys
import time import time
import socket import socket
from .irc_utils import message_parser from .irc_utils import message_parser
@ -104,7 +105,7 @@ class ClientMock:
ret = self.conn.sendall(encoded_line) ret = self.conn.sendall(encoded_line)
except BrokenPipeError: except BrokenPipeError:
raise ConnectionClosed() raise ConnectionClosed()
if self.ssl: # https://bugs.python.org/issue25951 if sys.version_info <= (3, 6) and self.ssl: # https://bugs.python.org/issue25951
assert ret == len(encoded_line), (ret, repr(encoded_line)) assert ret == len(encoded_line), (ret, repr(encoded_line))
else: else:
assert ret is None, ret assert ret is None, ret

View File

@ -1,4 +1,8 @@
import base64 import base64
import hashlib
import ecdsa
from ecdsa.util import sigencode_der, sigdecode_der
try: try:
import pyxmpp2_scram as scram import pyxmpp2_scram as scram
@ -9,6 +13,27 @@ from irctest import cases
from irctest import authentication from irctest import authentication
from irctest.irc_utils.message_parser import Message from irctest.irc_utils.message_parser import Message
ECDSA_KEY = """
-----BEGIN EC PARAMETERS-----
BggqhkjOPQMBBw==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
-----END EC PRIVATE KEY-----
"""
CHALLENGE = bytes(range(32))
assert len(CHALLENGE) == 32
class IdentityHash:
def __init__(self, data):
self._data = data
def digest(self):
return self._data
class SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper, class SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
cases.OptionalityHelper): cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN') @cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
@ -122,6 +147,36 @@ class SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
m = self.negotiateCapabilities(['sasl'], False) m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message({}, None, 'CAP', ['END'])) self.assertEqual(m, Message({}, None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('ECDSA-NIST256P-CHALLENGE')
def testEcdsa(self):
"""Test ECDSA authentication.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.ecdsa_nist256p_challenge],
username='jilles',
ecdsa_key=ECDSA_KEY,
)
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message({}, None, 'AUTHENTICATE', ['ECDSA-NIST256P-CHALLENGE']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message({}, None, 'AUTHENTICATE',
['amlsbGVz'])) # jilles
self.sendLine('AUTHENTICATE {}'.format(base64.b64encode(CHALLENGE).decode('ascii')))
m = self.getMessage()
self.assertMessageEqual(m, command='AUTHENTICATE')
sk = ecdsa.SigningKey.from_pem(ECDSA_KEY)
vk = sk.get_verifying_key()
signature = base64.b64decode(m.params[0])
try:
vk.verify(signature, CHALLENGE, hashfunc=IdentityHash, sigdecode=sigdecode_der)
except ecdsa.BadSignatureError:
raise AssertionError('Bad signature')
self.sendLine('900 * * foo :You are now logged in.')
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message({}, None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256') @cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256')
def testScram(self): def testScram(self):
"""Test SCRAM-SHA-256 authentication. """Test SCRAM-SHA-256 authentication.
@ -158,6 +213,10 @@ class SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode()) self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode())
self.assertEqual(properties, {'authzid': None, 'username': 'jilles'}) self.assertEqual(properties, {'authzid': None, 'username': 'jilles'})
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
self.assertEqual(m.params, ['+'], m)
@cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256') @cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256')
def testScramBadPassword(self): def testScramBadPassword(self):
"""Test SCRAM-SHA-256 authentication with a bad password. """Test SCRAM-SHA-256 authentication with a bad password.

View File

@ -1,3 +1,6 @@
import socket
import ssl
from irctest import tls from irctest import tls
from irctest import cases from irctest import cases
from irctest.exceptions import ConnectionClosed from irctest.exceptions import ConnectionClosed
@ -141,3 +144,92 @@ class TlsTestCase(cases.BaseClientTestCase):
self.acceptClient(tls_cert=BAD_CERT, tls_key=BAD_KEY) self.acceptClient(tls_cert=BAD_CERT, tls_key=BAD_KEY)
with self.assertRaises((ConnectionClosed, ConnectionResetError)): with self.assertRaises((ConnectionClosed, ConnectionResetError)):
m = self.getMessage() m = self.getMessage()
class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
def setUp(self):
super().setUp()
self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.insecure_server.bind(('', 0)) # Bind any free port
self.insecure_server.listen(1)
def tearDown(self):
self.insecure_server.close()
super().tearDown()
@cases.OptionalityHelper.skipUnlessSupportsCapability('sts')
def testSts(self):
tls_config = tls.TlsConfig(
enable=False,
trusted_fingerprints=[GOOD_FINGERPRINT])
# Connect client to insecure server
(hostname, port) = self.insecure_server.getsockname()
self.controller.run(
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
self.acceptClient(server=self.insecure_server)
# Send STS policy to client
m = self.getMessage()
self.assertEqual(m.command, 'CAP',
'First message is not CAP LS.')
self.assertEqual(m.params[0], 'LS',
'First message is not CAP LS.')
self.sendLine('CAP * LS :sts=port={}'.format(self.server.getsockname()[1]))
# "If the client is not already connected securely to the server
# at the requested hostname, it MUST close the insecure connection
# and reconnect securely on the stated port."
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
# Send the STS policy, over secure connection this time
self.sendLine('CAP * LS :sts=duration=10,port={}'.format(
self.server.getsockname()[1]))
# Make the client reconnect. It should reconnect to the secure server.
self.sendLine('ERROR :closing link')
self.acceptClient()
# Kill the client
self.controller.terminate()
# Run the client, still configured to connect to the insecure server
self.controller.run(
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
# The client should remember the STS policy and connect to the secure
# server
self.acceptClient()
@cases.OptionalityHelper.skipUnlessSupportsCapability('sts')
def testStsInvalidCertificate(self):
# Connect client to insecure server
(hostname, port) = self.insecure_server.getsockname()
self.controller.run(
hostname=hostname,
port=port,
auth=None,
)
self.acceptClient(server=self.insecure_server)
# Send STS policy to client
m = self.getMessage()
self.assertEqual(m.command, 'CAP',
'First message is not CAP LS.')
self.assertEqual(m.params[0], 'LS',
'First message is not CAP LS.')
self.sendLine('CAP * LS :sts=port={}'.format(self.server.getsockname()[1]))
# The client will reconnect to the TLS port. Unfortunately, it does
# not trust its fingerprint.
with self.assertRaises((ssl.SSLError, socket.error)):
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)

View File

@ -45,6 +45,7 @@ TEMPLATE_SSL_CONFIG = """
class CharybdisController(BaseServerController, DirectoryBasedController): class CharybdisController(BaseServerController, DirectoryBasedController):
software_name = 'Charybdis' software_name = 'Charybdis'
supported_sasl_mechanisms = set() supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
def create_config(self): def create_config(self):
super().create_config() super().create_config()
with self.open_file('server.conf'): with self.open_file('server.conf'):

View File

@ -4,12 +4,13 @@ from irctest.basecontrollers import BaseClientController, NotImplementedByContro
class GircController(BaseClientController): class GircController(BaseClientController):
software_name = 'gIRC' software_name = 'gIRC'
supported_sasl_mechanisms = ['PLAIN']
supported_capabilities = set() # Not exhaustive
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.directory = None self.directory = None
self.proc = None self.proc = None
self.supported_sasl_mechanisms = ['PLAIN']
def kill(self): def kill(self):
if self.proc: if self.proc:

View File

@ -43,6 +43,8 @@ TEMPLATE_SSL_CONFIG = """
class HybridController(BaseServerController, DirectoryBasedController): class HybridController(BaseServerController, DirectoryBasedController):
software_name = 'Hybrid' software_name = 'Hybrid'
supported_sasl_mechanisms = set() supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
def create_config(self): def create_config(self):
super().create_config() super().create_config()
with self.open_file('server.conf'): with self.open_file('server.conf'):

View File

@ -30,6 +30,8 @@ TEMPLATE_SSL_CONFIG = """
class InspircdController(BaseServerController, DirectoryBasedController): class InspircdController(BaseServerController, DirectoryBasedController):
software_name = 'InspIRCd' software_name = 'InspIRCd'
supported_sasl_mechanisms = set() supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
def create_config(self): def create_config(self):
super().create_config() super().create_config()
with self.open_file('server.conf'): with self.open_file('server.conf'):

View File

@ -2,6 +2,7 @@ import os
import subprocess import subprocess
from irctest import authentication from irctest import authentication
from irctest import tls
from irctest.basecontrollers import NotImplementedByController from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseClientController, DirectoryBasedController from irctest.basecontrollers import BaseClientController, DirectoryBasedController
@ -30,14 +31,19 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
supported_sasl_mechanisms = { supported_sasl_mechanisms = {
'PLAIN', 'ECDSA-NIST256P-CHALLENGE', 'SCRAM-SHA-256', 'EXTERNAL', 'PLAIN', 'ECDSA-NIST256P-CHALLENGE', 'SCRAM-SHA-256', 'EXTERNAL',
} }
supported_capabilities = set(['sts']) # Not exhaustive
def create_config(self): def create_config(self):
super().create_config() create_config = super().create_config()
if create_config:
with self.open_file('bot.conf'): with self.open_file('bot.conf'):
pass pass
with self.open_file('conf/users.conf'): with self.open_file('conf/users.conf'):
pass pass
def run(self, hostname, port, auth, tls_config): def run(self, hostname, port, auth, tls_config=None):
if tls_config is None:
tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[])
# Runs a client with the config given as arguments # Runs a client with the config given as arguments
assert self.proc is None assert self.proc is None
self.create_config() self.create_config()

View File

@ -66,6 +66,8 @@ class MammonController(BaseServerController, DirectoryBasedController):
supported_sasl_mechanisms = { supported_sasl_mechanisms = {
'PLAIN', 'ECDSA-NIST256P-CHALLENGE', 'PLAIN', 'ECDSA-NIST256P-CHALLENGE',
} }
supported_capabilities = set() # Not exhaustive
def create_config(self): def create_config(self):
super().create_config() super().create_config()
with self.open_file('server.conf'): with self.open_file('server.conf'):

View File

@ -153,6 +153,12 @@ class OragonoController(BaseServerController, DirectoryBasedController):
'PLAIN', 'PLAIN',
} }
_port_wait_interval = .01 _port_wait_interval = .01
supported_capabilities = set() # Not exhaustive
def create_config(self):
super().create_config()
with self.open_file('ircd.yaml'):
pass
def kill_proc(self): def kill_proc(self):
self.proc.kill() self.proc.kill()

View File

@ -24,6 +24,8 @@ class SopelController(BaseClientController):
supported_sasl_mechanisms = { supported_sasl_mechanisms = {
'PLAIN', 'PLAIN',
} }
supported_capabilities = set() # Not exhaustive
def __init__(self, test_config): def __init__(self, test_config):
super().__init__(test_config) super().__init__(test_config)
self.filename = next(tempfile._get_candidate_names()) + '.cfg' self.filename = next(tempfile._get_candidate_names()) + '.cfg'

View File

@ -19,6 +19,10 @@ class OptionalSaslMechanismNotSupported(unittest.SkipTest):
def __str__(self): def __str__(self):
return 'Unsupported SASL mechanism: {}'.format(self.args[0]) return 'Unsupported SASL mechanism: {}'.format(self.args[0])
class CapabilityNotSupported(unittest.SkipTest):
def __str__(self):
return 'Unsupported capability: {}'.format(self.args[0])
class NotRequiredBySpecifications(unittest.SkipTest): class NotRequiredBySpecifications(unittest.SkipTest):
def __str__(self): def __str__(self):
return 'Tests not required by the set of tested specification(s).' return 'Tests not required by the set of tested specification(s).'