Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------

Expand Down
29 changes: 29 additions & 0 deletions pyftpdlib/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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+
Expand Down Expand Up @@ -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
Copy link
Owner

@giampaolo giampaolo Aug 24, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is called when a client connects, as I suppose, then this should be a classmethod.


@classmethod
def get_ssl_context(cls):
if cls.ssl_context is None:
Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions pyftpdlib/test/clientcert.pem
Original file line number Diff line number Diff line change
@@ -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-----
136 changes: 136 additions & 0 deletions pyftpdlib/test/functional_ssl_client_certfile_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python

# Copyright (C) 2007-2016 Giampaolo Rodola' <g.rodola@gmail.com>.
# 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)
2 changes: 1 addition & 1 deletion pyftpdlib/test/test_functional_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down