diff --git a/docs/api.rst b/docs/api.rst index fa5c9303..cb506948 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -556,7 +556,11 @@ Extended handlers When True requires SSL/TLS to be established on the data channel. This means the user will have to issue PROT before PASV or PORT (default ``False``). + + .. data:: client_certfile + The path of the certificate to check the client certificate against. (default ``None``). + Extended authorizers -------------------- diff --git a/pyftpdlib/handlers.py b/pyftpdlib/handlers.py index c9515b3b..4bf23e7b 100644 --- a/pyftpdlib/handlers.py +++ b/pyftpdlib/handlers.py @@ -22,6 +22,11 @@ try: from OpenSSL import SSL # requires "pip install pyopenssl" + from OpenSSL.SSL import OP_NO_TICKET + from OpenSSL.SSL import SESS_CACHE_OFF + from OpenSSL.SSL import VERIFY_CLIENT_ONCE + from OpenSSL.SSL import VERIFY_FAIL_IF_NO_PEER_CERT + from OpenSSL.SSL import VERIFY_PEER except ImportError: SSL = None @@ -3414,6 +3419,8 @@ class TLS_FTPHandler(SSLConnection, FTPHandler): certfile = None keyfile = None ssl_protocol = SSL.SSLv23_METHOD + # client certificate configurable attributes + client_certfile = None # - SSLv2 is easily broken and is considered harmful and dangerous # - SSLv3 has several problems and is now dangerous # - Disable compression to prevent CRIME attacks for OpenSSL 1.0+ @@ -3446,10 +3453,24 @@ def __init__(self, conn, server, ioloop=None): self._pbsz = False self._prot = False self.ssl_context = self.get_ssl_context() + if self.client_certfile is not None: + self.ssl_context.set_verify(VERIFY_PEER | + VERIFY_FAIL_IF_NO_PEER_CERT | + VERIFY_CLIENT_ONCE, + self.verify_certs_callback) def __repr__(self): return FTPHandler.__repr__(self) + # Cannot be @classmethod, need instance to log + def verify_certs_callback(self, connection, x509, + errnum, errdepth, ok): + if not ok: + self.log("Bad client certificate detected.") + else: + self.log("Client certificate is valid.") + return ok + @classmethod def get_ssl_context(cls): if cls.ssl_context is None: @@ -3464,6 +3485,14 @@ def get_ssl_context(cls): if not cls.keyfile: cls.keyfile = cls.certfile cls.ssl_context.use_privatekey_file(cls.keyfile) + if cls.client_certfile is not None: + cls.ssl_context.set_verify(VERIFY_PEER | + VERIFY_FAIL_IF_NO_PEER_CERT | + VERIFY_CLIENT_ONCE, + cls.verify_certs_callback) + cls.ssl_context.load_verify_locations(cls.client_certfile) + cls.ssl_context.set_session_cache_mode(SESS_CACHE_OFF) + cls.ssl_options = cls.ssl_options | OP_NO_TICKET if cls.ssl_options: cls.ssl_context.set_options(cls.ssl_options) return cls.ssl_context diff --git a/pyftpdlib/test/clientcert.pem b/pyftpdlib/test/clientcert.pem new file mode 100644 index 00000000..4c755845 --- /dev/null +++ b/pyftpdlib/test/clientcert.pem @@ -0,0 +1,50 @@ +-----BEGIN CERTIFICATE----- +MIIDqzCCApOgAwIBAgIBADANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJVUzEO +MAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24xDDAKBgNVBAoMA1NMQjEM +MAoGA1UECwwDU1dUMQwwCgYDVQQDDANzd3QxFTATBgkqhkiG9w0BCQEWBmJwbG9z +dDAeFw0xNjA4MjMxMzQyMDZaFw0xNzA4MjMxMzQyMDZaMHAxCzAJBgNVBAYTAlVT +MQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEMMAoGA1UECgwDU0xC +MQwwCgYDVQQLDANTV1QxDDAKBgNVBAMMA3N3dDEVMBMGCSqGSIb3DQEJARYGYnBs +b3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArNnEnnUGpXLqnTTx +3td4OWQoKFBppifL6+r5y839CWT6GR3Xj5OlcJPXMKBO40H0StHOctshPL0/ZZeC +sNHT6O6c47nBc/pw3YX4GJjLJno0k1+xTSvmk+yhTF/i1ThDIq2YrlGWXHyo/jOe +gJc0T3L2u1Ivx+iOEAIP9uqBpAi3rhfZKBvVdDXb5J0TqouXt4jx5l8Fq577D9y4 +W61nmj7FlquxClhGIgsNbMtlBIMkALNLq3kY+TqYatjRy6aS6mb55TfObjP+IOgz +8Jln937hb89eJopirwKMzD1EnJgBamMPNJjIhDQlHklMwkgynIKnSJghbjKBusaE +2xZEPQIDAQABo1AwTjAdBgNVHQ4EFgQU2hMnpneH1sZ79iWoBLue1+7f4NwwHwYD +VR0jBBgwFoAU2hMnpneH1sZ79iWoBLue1+7f4NwwDAYDVR0TBAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEAdmPairr/lt63J3dfTSFNJTytvV1WW7Ak8NwH1hdheaYy +Tx9ffjRIZv9WyHEWb/1YCkKo08LqgnVh07HfW0JY1hqD0SwoHtPexTIgBnOsKvCr +q1gQjFuDg2wVV7cecYPQFYv9jweIe62OCapKl8PjmXii+qnxY/Qbbyx9bGYbR1k4 +KJm073WwiqXXCS1JgOj9WH3I1Qa2Ptb6RO+Woy7ItA5ftQSp4EMTwhygOK0j0w9V +11MAPUtZ8/rTiD17HHVzKfbNmx4E6dtBV9E/gn464lrdNhxEaeN2wW42FU+CzeVa +/aYQbQTSXpI5tX7QTAcQrMAqp75EBMdYj5+9bRXIvw== +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCs2cSedQalcuqd +NPHe13g5ZCgoUGmmJ8vr6vnLzf0JZPoZHdePk6Vwk9cwoE7jQfRK0c5y2yE8vT9l +l4Kw0dPo7pzjucFz+nDdhfgYmMsmejSTX7FNK+aT7KFMX+LVOEMirZiuUZZcfKj+ +M56AlzRPcva7Ui/H6I4QAg/26oGkCLeuF9koG9V0NdvknROqi5e3iPHmXwWrnvsP +3LhbrWeaPsWWq7EKWEYiCw1sy2UEgyQAs0ureRj5Ophq2NHLppLqZvnlN85uM/4g +6DPwmWf3fuFvz14mimKvAozMPUScmAFqYw80mMiENCUeSUzCSDKcgqdImCFuMoG6 +xoTbFkQ9AgMBAAECggEBAKRQ3G36N9g+VzQdKbU6xipgwSAZ2WU/vcZG+TI6Xsp4 +eJw51zrBE+viTxYFvxihETezHXvoPj98dHECSBYJUlbDxtdhNbsoH/UmrwPK9Ixe +be6PcIA5NJf4whlVqdAiDQhBWLyWCMdhJlGJBqudkffZBR5r8corlCk5nK2Qnq8s +ncaaO1A7RLFXXCiwWdAi7bJEHZB2XFQYMctfJ+/m02s0IhIRmusH4XovdTucfykI +FstXtd5+AOfqsOkTn4kGOmG4ObKOXOp1XAjTTIV3xxU0CzYxE1CPhlimkAVLuRPs +bInojdtm80ZQY68xCOvUsq+WMIB+dKkeJKeTI6e/b+0CgYEA3nDQFto7pqtVZLUu +YVTlR6vxrXEYl8FIes8WlICBWXPnviFoiSLTB5SPF1Yvv/LKFkYQ96i2ep8FvZEy +Rbzt1xOOF9GWtO4lc6cBIzh31POJgY2B2PAo7qmhpyhutCaaotf5ukpx35eGlj5v +RT0wDN7Xt6S4arqgbngUdtHLhqsCgYEAxu2rv/GVU/DL3VuBHIky/OKFeL94EnIA +UUm5SRvd0SeHgcjd+EHG/u1nH1XC+Dwiw75Dsv/nqhekFqaQczYZlUrKa+mkdofV +i8DbXHies6qTwvYUIorhaiAoY+iRwJ/6d20oep+3fDSPPcVj7PakZpCK0sAesWX7 +09muN0vLALcCgYAKr0iPkHQFEX3MlJdhvX417yBwwFn6ECK3I3NmNrX/4f1juJ8Y +1z9jwdMNv+oTQkpKv5rZCpWZVkIkVPEhQG38Qsg0hLDEiBvsbj0zv+ahqAEW5AE0 +tnSA4k0Nhneq15/d6pnoROMrZk/kr6MQpFvGgn3CKHtjRQunwsTY4ELyeQKBgQCN +zlNGqvJmOhs5msc5Dly4hMncv7DahUXQrJtWkHTZajJgxE3ncQxoIdgHMF2iE0w8 ++V7NNTtxtxSTyPzkBEbMc9pEfvNsQ3xo+XvmOV34ebqHml/UF+iEfJQOVHXCOMiV +Zc0bTMvB0L3jrNiEzXV4X8V2Ytn+X9LavCxC4ta9lQKBgHTQNDz+qdiLoeX68HKp +b5jd1H7nozWdcbcAO5tKNbSZvnY05QCjC+WkuIeUkKc2zcctIy306/iFRApguElm +z8sm8NnJIkJeRx2XmEE5Lcn64se3ml7qVlcFaYrVW8hDrrvlUAwzu+ZoPVD3DSiQ +EOnPOa2mEwR9YPyPaKdq+fkt +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/pyftpdlib/test/functional_ssl_client_certfile_tests.py b/pyftpdlib/test/functional_ssl_client_certfile_tests.py new file mode 100644 index 00000000..aae87d68 --- /dev/null +++ b/pyftpdlib/test/functional_ssl_client_certfile_tests.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python + +# Copyright (C) 2007-2016 Giampaolo Rodola' . +# Use of this source code is governed by MIT license that can be +# found in the LICENSE file. +# +# +# Does not follow naming convention of other tests because this +# CANNOT be run in the same test suite with test_functional_ssl. +# The test parallelism causes SSL errors when there should be none +# Please run these tests separately + +import ftplib +import os +import sys + +import OpenSSL # requires "pip install pyopenssl" + +from pyftpdlib.handlers import TLS_FTPHandler +from pyftpdlib.test import configure_logging +from pyftpdlib.test import remove_test_files +from pyftpdlib.test import ThreadedTestFTPd +from pyftpdlib.test import TIMEOUT +from pyftpdlib.test import unittest +from pyftpdlib.test import VERBOSITY +from _ssl import SSLError + + +FTPS_SUPPORT = hasattr(ftplib, 'FTP_TLS') +if sys.version_info < (2, 7): + FTPS_UNSUPPORT_REASON = "requires python 2.7+" +else: + FTPS_UNSUPPORT_REASON = "FTPS test skipped" + +CERTFILE = os.path.abspath(os.path.join(os.path.dirname(__file__), + 'keycert.pem')) +CLIENT_CERTFILE = os.path.abspath(os.path.join(os.path.dirname(__file__), + 'clientcert.pem')) + +del OpenSSL + + +if FTPS_SUPPORT: + class FTPSClient(ftplib.FTP_TLS): + """A modified version of ftplib.FTP_TLS class which implicitly + secure the data connection after login(). + """ + + def login(self, *args, **kwargs): + ftplib.FTP_TLS.login(self, *args, **kwargs) + self.prot_p() + + class FTPSServerAuth(ThreadedTestFTPd): + """A threaded FTPS server that forces client certificate + authentication used for functional testing. + """ + handler = TLS_FTPHandler + handler.certfile = CERTFILE + handler.client_certfile = CLIENT_CERTFILE + + +# ===================================================================== +# dedicated FTPS tests with client authentication +# ===================================================================== + + +@unittest.skipUnless(FTPS_SUPPORT, FTPS_UNSUPPORT_REASON) +class TestFTPS(unittest.TestCase): + """Specific tests for TLS_FTPHandler class.""" + + def setUp(self): + self.server = FTPSServerAuth() + self.server.start() + + def tearDown(self): + self.client.close() + self.server.stop() + + def assertRaisesWithMsg(self, excClass, msg, callableObj, *args, **kwargs): + try: + callableObj(*args, **kwargs) + except excClass as err: + if str(err) == msg: + return + raise self.failureException("%s != %s" % (str(err), msg)) + else: + if hasattr(excClass, '__name__'): + excName = excClass.__name__ + else: + excName = str(excClass) + raise self.failureException("%s not raised" % excName) + + def test_auth_client_cert(self): + self.client = ftplib.FTP_TLS(timeout=TIMEOUT, certfile=CLIENT_CERTFILE) + self.client.connect(self.server.host, self.server.port) + # secured + try: + self.client.login() + except Exception: + self.fail("login with certificate should work") + + def test_auth_client_nocert(self): + self.client = ftplib.FTP_TLS(timeout=TIMEOUT) + self.client.connect(self.server.host, self.server.port) + try: + self.client.login() + except SSLError as e: + # client should not be able to log in + if "SSLV3_ALERT_HANDSHAKE_FAILURE" in e.reason: + pass + else: + self.fail("Incorrect SSL error with" + + " missing client certificate") + else: + self.fail("Client able to log in with no certificate") + + def test_auth_client_badcert(self): + self.client = ftplib.FTP_TLS(timeout=TIMEOUT, certfile=CERTFILE) + self.client.connect(self.server.host, self.server.port) + try: + self.client.login() + except Exception as e: + # client should not be able to log in + if "TLSV1_ALERT_UNKNOWN_CA" in e.reason: + pass + else: + self.fail("Incorrect SSL error with bad client certificate") + else: + self.fail("Client able to log in with bad certificate") + +configure_logging() +remove_test_files() + + +if __name__ == '__main__': + unittest.main(verbosity=VERBOSITY) diff --git a/pyftpdlib/test/test_functional_ssl.py b/pyftpdlib/test/test_functional_ssl.py index 16ce4c35..55e26f00 100644 --- a/pyftpdlib/test/test_functional_ssl.py +++ b/pyftpdlib/test/test_functional_ssl.py @@ -210,7 +210,7 @@ class TestCornerCasesTLSMixin(TLSTestMixin, TestCornerCases): @unittest.skipUnless(FTPS_SUPPORT, FTPS_UNSUPPORT_REASON) class TestFTPS(unittest.TestCase): - """Specific tests fot TSL_FTPHandler class.""" + """Specific tests for TLS_FTPHandler class.""" def setUp(self): self.server = FTPSServer()