diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a63df..0537a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased +**New features:** +* Support SSL encrypted connection to Tarantool EE (closes [#22](https://github.com/igorcoding/asynctnt/issues/22)) + ## v2.0.1 * Fixed an issue with encoding datetimes less than 01-01-1970 (fixes [#29](https://github.com/igorcoding/asynctnt/issues/29)) * Fixed "Edit on Github" links in docs (fixes [#26](https://github.com/igorcoding/asynctnt/issues/26)) diff --git a/asynctnt/__init__.py b/asynctnt/__init__.py index d0aad74..e366826 100644 --- a/asynctnt/__init__.py +++ b/asynctnt/__init__.py @@ -1,3 +1,4 @@ +from .const import Transport from .connection import Connection, connect from .iproto.protocol import ( Iterator, Response, TarantoolTuple, PushIterator, diff --git a/asynctnt/connection.py b/asynctnt/connection.py index 5ad289d..145d82e 100644 --- a/asynctnt/connection.py +++ b/asynctnt/connection.py @@ -1,16 +1,18 @@ import asyncio import enum import functools +import ssl import os from typing import Optional, Union from .api import Api +from .const import Transport from .exceptions import TarantoolDatabaseError, \ - ErrorCode, TarantoolError + ErrorCode, TarantoolError, SSLError from .iproto import protocol from .log import logger from .stream import Stream -from .utils import get_running_loop +from .utils import get_running_loop, PY_37 __all__ = ( 'Connection', 'connect', 'ConnectionState' @@ -27,11 +29,13 @@ class ConnectionState(enum.IntEnum): class Connection(Api): __slots__ = ( - '_host', '_port', '_username', '_password', - '_fetch_schema', '_auto_refetch_schema', '_initial_read_buffer_size', - '_encoding', '_connect_timeout', '_reconnect_timeout', - '_request_timeout', '_ping_timeout', '_loop', '_state', '_state_prev', - '_transport', '_protocol', + '_host', '_port', '_parameter_transport', '_ssl_key_file', + '_ssl_cert_file', '_ssl_ca_file', '_ssl_ciphers', + '_username', '_password', '_fetch_schema', + '_auto_refetch_schema', '_initial_read_buffer_size', + '_encoding', '_connect_timeout', '_ssl_handshake_timeout', + '_reconnect_timeout', '_request_timeout', '_ping_timeout', + '_loop', '_state', '_state_prev', '_transport', '_protocol', '_disconnect_waiter', '_reconnect_task', '_connect_lock', '_disconnect_lock', '_ping_task', '__create_task' @@ -40,11 +44,17 @@ class Connection(Api): def __init__(self, *, host: str = '127.0.0.1', port: Union[int, str] = 3301, + transport: Optional[Transport] = Transport.DEFAULT, + ssl_key_file: Optional[str] = None, + ssl_cert_file: Optional[str] = None, + ssl_ca_file: Optional[str] = None, + ssl_ciphers: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, fetch_schema: bool = True, auto_refetch_schema: bool = True, connect_timeout: float = 3., + ssl_handshake_timeout: float = 3., request_timeout: float = -1., reconnect_timeout: float = 1. / 3., ping_timeout: float = 5., @@ -78,6 +88,22 @@ def __init__(self, *, :param port: Tarantool port (pass ``/path/to/sockfile`` to connect ot unix socket) + :param transport: + This parameter can be used to configure traffic encryption. + Pass ``asynctnt.Transport.SSL`` value to enable SSL + encryption (by default there is no encryption) + :param ssl_key_file: + A path to a private SSL key file. + Optional, mandatory if server uses CA file + :param ssl_cert_file: + A path to an SSL certificate file. + Optional, mandatory if server uses CA file + :param ssl_ca_file: + A path to a trusted certificate authorities (CA) file. + Optional + :param ssl_ciphers: + A colon-separated (:) list of SSL cipher suites + the connection can use. Optional :param username: Username to use for auth (if ``None`` you are connected as a guest) @@ -93,6 +119,10 @@ def __init__(self, *, be checked by Tarantool, so no errors will occur :param connect_timeout: Time in seconds how long to wait for connecting to socket + :param ssl_handshake_timeout: + Time in seconds to wait for the TLS handshake to complete + before aborting the connection (used only for a TLS + connection). Supported for Python 3.7 or newer :param request_timeout: Request timeout (in seconds) for all requests (by default there is no timeout) @@ -116,6 +146,13 @@ def __init__(self, *, super().__init__() self._host = host self._port = port + + self._parameter_transport = transport + self._ssl_key_file = ssl_key_file + self._ssl_cert_file = ssl_cert_file + self._ssl_ca_file = ssl_ca_file + self._ssl_ciphers = ssl_ciphers + self._username = username self._password = password self._fetch_schema = False if fetch_schema is None else fetch_schema @@ -131,6 +168,7 @@ def __init__(self, *, self._encoding = encoding or 'utf-8' self._connect_timeout = connect_timeout + self._ssl_handshake_timeout = ssl_handshake_timeout self._reconnect_timeout = reconnect_timeout or 0 self._request_timeout = request_timeout self._ping_timeout = ping_timeout or 0 @@ -220,6 +258,54 @@ def protocol_factory(self, on_connection_lost=self.connection_lost, loop=self._loop) + def _create_ssl_context(self): + try: + if hasattr(ssl, 'TLSVersion'): + # Since python 3.7 + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # Reset to default OpenSSL values. + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + # Require TLSv1.2, because other protocol versions don't seem + # to support the GOST cipher. + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.maximum_version = ssl.TLSVersion.TLSv1_2 + else: + # Deprecated, but it works for python < 3.7 + context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + + if self._ssl_cert_file: + # If the password argument is not specified and a password is + # required, OpenSSL’s built-in password prompting mechanism + # will be used to interactively prompt the user for a password. + # + # We should disable this behaviour, because a python + # application that uses the connector unlikely assumes + # interaction with a human + a Tarantool implementation does + # not support this at least for now. + def password_raise_error(): + raise SSLError("a password for decrypting the private " + + "key is unsupported") + context.load_cert_chain(certfile=self._ssl_cert_file, + keyfile=self._ssl_key_file, + password=password_raise_error) + + if self._ssl_ca_file: + context.load_verify_locations(cafile=self._ssl_ca_file) + context.verify_mode = ssl.CERT_REQUIRED + # A Tarantool implementation does not check hostname. We don't + # do that too. As a result we don't set here: + # context.check_hostname = True + + if self._ssl_ciphers: + context.set_ciphers(self._ssl_ciphers) + + return context + except SSLError as e: + raise + except Exception as e: + raise SSLError(e) + async def _connect(self, return_exceptions: bool = True): if self._loop is None: self._loop = get_running_loop() @@ -246,6 +332,12 @@ async def full_connect(): while True: connected_fut = _create_future(self._loop) + ssl_context = None + ssl_handshake_timeout = None + if self._parameter_transport == Transport.SSL: + ssl_context = self._create_ssl_context() + ssl_handshake_timeout = self._ssl_handshake_timeout + if self._host.startswith('unix/'): unix_path = self._port assert isinstance(unix_path, str), \ @@ -257,16 +349,34 @@ async def full_connect(): 'Unix socket `{}` not found'.format( unix_path) - conn = self._loop.create_unix_connection( - functools.partial(self.protocol_factory, - connected_fut), - unix_path - ) + if PY_37: + conn = self._loop.create_unix_connection( + functools.partial(self.protocol_factory, + connected_fut), + unix_path, + ssl=ssl_context, + ssl_handshake_timeout=ssl_handshake_timeout) + else: + conn = self._loop.create_unix_connection( + functools.partial(self.protocol_factory, + connected_fut), + unix_path, + ssl=ssl_context) + else: - conn = self._loop.create_connection( - functools.partial(self.protocol_factory, - connected_fut), - self._host, self._port) + if PY_37: + conn = self._loop.create_connection( + functools.partial(self.protocol_factory, + connected_fut), + self._host, self._port, + ssl=ssl_context, + ssl_handshake_timeout=ssl_handshake_timeout) + else: + conn = self._loop.create_connection( + functools.partial(self.protocol_factory, + connected_fut), + self._host, self._port, + ssl=ssl_context) tr, pr = await conn @@ -337,6 +447,8 @@ async def full_connect(): if return_exceptions: self._reconnect_task = None + if isinstance(e, ssl.SSLError): + e = SSLError(e) raise e logger.exception(e) diff --git a/asynctnt/const.py b/asynctnt/const.py new file mode 100644 index 0000000..ae597b6 --- /dev/null +++ b/asynctnt/const.py @@ -0,0 +1,5 @@ +import enum + +class Transport(enum.IntEnum): + DEFAULT = 1 + SSL = 2 diff --git a/asynctnt/exceptions.py b/asynctnt/exceptions.py index 00fa09a..ceead6a 100644 --- a/asynctnt/exceptions.py +++ b/asynctnt/exceptions.py @@ -42,6 +42,12 @@ class TarantoolNotConnectedError(TarantoolNetworkError): """ pass +class SSLError(TarantoolError): + """ + Raised when something is wrong with encrypted connection + """ + pass + class ErrorCode(enum.IntEnum): """ diff --git a/asynctnt/instance.py b/asynctnt/instance.py index 38f2bbc..9d2f32f 100644 --- a/asynctnt/instance.py +++ b/asynctnt/instance.py @@ -26,6 +26,7 @@ ) from asynctnt.utils import get_running_loop +from asynctnt.const import Transport VERSION_STRING_REGEX = re.compile(r'\s*([\d.]+).*') @@ -90,6 +91,11 @@ class TarantoolInstance(metaclass=abc.ABCMeta): def __init__(self, *, host='127.0.0.1', port=3301, + transport=Transport.DEFAULT, + ssl_key_file=None, + ssl_cert_file=None, + ssl_ca_file=None, + ssl_ciphers=None, console_host=None, console_port=3302, replication_source=None, @@ -113,6 +119,22 @@ def __init__(self, *, to be listening on (default = 127.0.0.1) :param port: The port which Tarantool instance is going to be listening on (default = 3301) + :param transport: + This parameter can be used to configure traffic encryption. + Pass ``asynctnt.Transport.SSL`` value to enable SSL + encryption (by default there is no encryption) + :param str ssl_key_file: + A path to a private SSL key file. + Mandatory if server uses SSL encryption + :param str ssl_cert_file: + A path to an SSL certificate file. + Mandatory if server uses SSL encryption + :param str ssl_ca_file: + A path to a trusted certificate authorities (CA) file. + Optional + :param str ssl_ciphers: + A colon-separated (:) list of SSL cipher suites + the server can use. Optional :param console_host: The host which Tarantool console is going to be listening on (to execute admin commands) (default = host) @@ -147,6 +169,11 @@ def __init__(self, *, self._host = host self._port = port + self._parameter_transport = transport + self._ssl_key_file = ssl_key_file + self._ssl_cert_file = ssl_cert_file + self._ssl_ca_file = ssl_ca_file + self._ssl_ciphers = ssl_ciphers self._console_host = console_host or host self._console_port = console_port self._replication_source = replication_source @@ -248,7 +275,7 @@ def _create_initlua_template(self): return check_version_internal(expected, version) end local cfg = { - listen = "${host}:${port}", + listen = "${host}:${port}${listen_params}", wal_mode = "${wal_mode}", custom_proc_title = "${custom_proc_title}", slab_alloc_arena = ${slab_alloc_arena}, @@ -289,9 +316,23 @@ def _render_initlua(self): if self._specify_work_dir: work_dir = '"' + self._root + '"' + listen_params = '' + if self._parameter_transport == Transport.SSL: + listen_params = "?transport=ssl&" + if self._ssl_key_file: + listen_params += "ssl_key_file={}&".format(self._ssl_key_file) + if self._ssl_cert_file: + listen_params += "ssl_cert_file={}&".format(self._ssl_cert_file) + if self._ssl_ca_file: + listen_params += "ssl_ca_file={}&".format(self._ssl_ca_file) + if self._ssl_ciphers: + listen_params += "ssl_ciphers={}&".format(self._ssl_ciphers) + listen_params = listen_params[:-1] + d = { 'host': self._host, 'port': self._port, + 'listen_params': listen_params, 'console_host': self._console_host, 'console_port': self._console_port, 'wal_mode': self._wal_mode, @@ -589,7 +630,7 @@ def bin_version(self) -> Optional[tuple]: proc = subprocess.Popen([self._command_to_run, '-V'], stdout=subprocess.PIPE) output = proc.stdout.read().decode() - version_str = output.split('\n')[0].split(' ')[1] + version_str = output.split('\n')[0].replace('Tarantool ', '').replace('Enterprise ', '') return self._parse_version(version_str) def command(self, cmd, print_greeting=True): diff --git a/docs/examples.md b/docs/examples.md index 480861f..e1e51d7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -65,3 +65,32 @@ async def main(): asyncio.run(main()) ``` + +## Connect with SSL encryption +```python +import asyncio +import asynctnt + + +async def main(): + conn = asynctnt.Connection(host='127.0.0.1', + port=3301, + transport=asynctnt.Transport.SSL, + ssl_key_file='./ssl/host.key', + ssl_cert_file='./ssl/host.crt', + ssl_ca_file='./ssl/ca.crt', + ssl_ciphers='ECDHE-RSA-AES256-GCM-SHA384') + await conn.connect() + + resp = await conn.ping() + print(resp) + + await conn.disconnect() + +asyncio.run(main()) +``` + +Stdout: +``` + +``` diff --git a/tests/files/ssl/ca.crt b/tests/files/ssl/ca.crt new file mode 100644 index 0000000..013f548 --- /dev/null +++ b/tests/files/ssl/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLzCCAhegAwIBAgIUMwa7m6dtjVYPK5iZAMX8YUuHtxEwDQYJKoZIhvcNAQEL +BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y +MjA2MTYwODQzMThaFw00NDExMTkwODQzMThaMCcxCzAJBgNVBAYTAlVTMRgwFgYD +VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC923p9pD1ajiAPsM2W6cnjSkexHX2+sJeaLXL6zdFeUjLYRAnfzJ9xVih7 +91yWbuJ9OAswWmz83JrtSm1GqZpFucSz5pFqW2AVrhX5TezlxyH9QwPl+Scu1kCd ++wu7Fgkuw7a0SOpYafPQ6smucCWbxkyZTNgysNuWswykal4VCWyekaY/OojEImoG +smGOXe1Pr2x8XsiWVau1UJ0jj/vh5VzF05mletaUOoQ+iorIHAfnOm2K53jAZlNG +X83VJ1ijSDwiKcnFKcQqlq2Zt88UpxMMv0UyFbDCrOj5qfBbAvzZj5IgUi/NvoZz +M+lzwT+/0mADkAHB6EVa4R29zM+fAgMBAAGjUzBRMB0GA1UdDgQWBBSloRx6dBUI +gJb0yzP2c5zQdQQ+2TAfBgNVHSMEGDAWgBSloRx6dBUIgJb0yzP2c5zQdQQ+2TAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCCUEnzpu8hZAckICLR +5JRDUiHJ3yJ5iv0b9ChNaz/AQBQGRE8bOPC2M/ZG1RuuQ8IbRbzK0fy1ty9KpG2D +JC9iDL6zPOC3e5x2H8Gxbhvjz4QnHPbYTfdJSmX5tJyNIrJ77g4SW5g8eFApTHyY +5KwRD3IDEu4pZNGsM7l0ODBC/4lvR8u7wPJDGyJBpE3uAKC20XqbG8BWm3kPb9+T +wE4Ak/FEXcwARB0fJ6Jni9iK3TeReyB3rpsYJa4N9iY6f1qNy4qQZ8Va6EWPSNnB +FhvCIYt4LdgM9ffUuHPrCX7qdgSNiL4VijgLaEHjFUUlLb6NHgQfYx/JG7wstiKs +Syzb +-----END CERTIFICATE----- diff --git a/tests/files/ssl/empty b/tests/files/ssl/empty new file mode 100644 index 0000000..e69de29 diff --git a/tests/files/ssl/generate.sh b/tests/files/ssl/generate.sh new file mode 100755 index 0000000..437ecf7 --- /dev/null +++ b/tests/files/ssl/generate.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -xeuo pipefail +# An example how-to re-generate testing certificates (because usually +# TLS certificates have expiration dates and some day they will expire). +# +# The instruction is valid for: +# +# $ openssl version +# OpenSSL 3.0.2 15 Mar 2022 (Library: OpenSSL 3.0.2 15 Mar 2022) + +cat < domains_localhost.ext +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 +EOF + +cat < domains_invalidhost.ext +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names +[alt_names] +DNS.1 = invalidhostname +EOF + +openssl req -x509 -nodes -new -sha256 -days 8192 -newkey rsa:2048 -keyout ca.key -out ca.pem -subj "/C=US/CN=Example-Root-CA" +openssl x509 -outform pem -in ca.pem -out ca.crt + +openssl req -new -nodes -newkey rsa:2048 -keyout localhost.key -out localhost.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost" +openssl x509 -req -sha256 -days 8192 -in localhost.csr -CA ca.pem -CAkey ca.key -CAcreateserial -extfile domains_localhost.ext -out localhost.crt +openssl x509 -req -sha256 -days 8192 -in localhost.csr -CA ca.pem -CAkey ca.key -CAcreateserial -extfile domains_invalidhost.ext -out invalidhost.crt diff --git a/tests/files/ssl/invalidhost.crt b/tests/files/ssl/invalidhost.crt new file mode 100644 index 0000000..de28671 --- /dev/null +++ b/tests/files/ssl/invalidhost.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkjCCAnqgAwIBAgIUV7NbprG6FEvrSP0kZ7pT9s7eN7swDQYJKoZIhvcNAQEL +BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y +MjA2MTYwODQzMThaFw00NDExMTkwODQzMThaMGcxCzAJBgNVBAYTAlVTMRIwEAYD +VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt +cGxlLUNlcnRpZmljYXRlczESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqGqKNkOVMGeIClmjLRf02UhtpYcGYVmiblpB +rqbI7eXKKIXMm4ppEEC/1YMVx/iYNYUK0xXxtzZUe1R6L5PYKAm1X+EQ4Sipyj/s +J+qsHxC65mavKB0ylZLZxAjZbiqBBYWwt0uz6ihHAtNXmoBzCE/mTRI3vTOd+CGQ +EI5pLGB85UuyvTfMKFwV9cTfltqGNyAZ670TFxtIwLeGuExfAFTVyofFWb8Kniby +EwKm/1giFl0HrKsHzPljKjlug6lcUeGxooTUJ9sxe6zPYGy2c6EqyV62/UVzgxv9 +LNejeh3vlFmQbeawrwvQSMNi+sVuiaYmq/FIw5e4pUYUTjf+SQIDAQABo3YwdDAf +BgNVHSMEGDAWgBSloRx6dBUIgJb0yzP2c5zQdQQ+2TAJBgNVHRMEAjAAMAsGA1Ud +DwQEAwIE8DAaBgNVHREEEzARgg9pbnZhbGlkaG9zdG5hbWUwHQYDVR0OBBYEFNpJ +/WkoMwKCdo0w0HV8aYm1m7ayMA0GCSqGSIb3DQEBCwUAA4IBAQC2tCfqPF2QrieZ +5632SyuX9oDzBCPQv2vi68QRtL+VxjmJ+IPLHdpZ96jTM7pYIAQ5QVm357JXLixU +NJ0eqgGIFrY4Evx91AGEAX20Ccn8CCXK3LsG1z1UWrvH/txEyOecuLCukaDI5ejq +z1/CKJhxF7bBfukfG2X8qWqqUNRQpkdQObMwZ6Np/GhITIDldxRMIaP05pUGPybR +CrEiC5F5lwgVAwlNhnfJuBcH3XMKWFZuiyur3O6PfSmUByainSnLY94RefofyEct +t20ikQssE6XcX/soTtmwOvIGHHMGcuKBbTwlF0dxv9pLrikpXrv0sf3mT+abUqND +oPmVcDJp +-----END CERTIFICATE----- diff --git a/tests/files/ssl/localhost.crt b/tests/files/ssl/localhost.crt new file mode 100644 index 0000000..765b843 --- /dev/null +++ b/tests/files/ssl/localhost.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkjCCAnqgAwIBAgIUI7y4bpqOVjvp9aEzUlsSO4pZgjAwDQYJKoZIhvcNAQEL +BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y +MjA2MTYwODQzMThaFw00NDExMTkwODQzMThaMGcxCzAJBgNVBAYTAlVTMRIwEAYD +VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt +cGxlLUNlcnRpZmljYXRlczESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqGqKNkOVMGeIClmjLRf02UhtpYcGYVmiblpB +rqbI7eXKKIXMm4ppEEC/1YMVx/iYNYUK0xXxtzZUe1R6L5PYKAm1X+EQ4Sipyj/s +J+qsHxC65mavKB0ylZLZxAjZbiqBBYWwt0uz6ihHAtNXmoBzCE/mTRI3vTOd+CGQ +EI5pLGB85UuyvTfMKFwV9cTfltqGNyAZ670TFxtIwLeGuExfAFTVyofFWb8Kniby +EwKm/1giFl0HrKsHzPljKjlug6lcUeGxooTUJ9sxe6zPYGy2c6EqyV62/UVzgxv9 +LNejeh3vlFmQbeawrwvQSMNi+sVuiaYmq/FIw5e4pUYUTjf+SQIDAQABo3YwdDAf +BgNVHSMEGDAWgBSloRx6dBUIgJb0yzP2c5zQdQQ+2TAJBgNVHRMEAjAAMAsGA1Ud +DwQEAwIE8DAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwHQYDVR0OBBYEFNpJ +/WkoMwKCdo0w0HV8aYm1m7ayMA0GCSqGSIb3DQEBCwUAA4IBAQC2UFwSoqAMfg1h +xhYauemq13+JXPOnfoR74WzJc8Wva51Bqr8YpVxXU8GCViZKdWi/6sT5h//M4Zrp +wmcUruAQinRUy7RzKoXFHL7g6hQOE440gqaePE/PvjTde8l7FeiGTCSfAqIIFpsz +8YhVajenrzt9ppaHnad/N59uCnIULZrezRq8wJl8Zw82IR/Szcu/4O/tSimYuleY +pNX1h5w2mfpNmKeXkseU8cid1GhCnBg2FK6t6xZ4sSCL2nlpNKsbYvLg5rViRavO +7roUcU4BKK5NnGuYOPKYycSpC500V+shnCq4vTZSsPTOT2dHdMMK5HguxzHxixQv +yPeWBYqy +-----END CERTIFICATE----- diff --git a/tests/files/ssl/localhost.key b/tests/files/ssl/localhost.key new file mode 100644 index 0000000..5fbcfba --- /dev/null +++ b/tests/files/ssl/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCoaoo2Q5UwZ4gK +WaMtF/TZSG2lhwZhWaJuWkGupsjt5coohcybimkQQL/VgxXH+Jg1hQrTFfG3NlR7 +VHovk9goCbVf4RDhKKnKP+wn6qwfELrmZq8oHTKVktnECNluKoEFhbC3S7PqKEcC +01eagHMIT+ZNEje9M534IZAQjmksYHzlS7K9N8woXBX1xN+W2oY3IBnrvRMXG0jA +t4a4TF8AVNXKh8VZvwqeJvITAqb/WCIWXQesqwfM+WMqOW6DqVxR4bGihNQn2zF7 +rM9gbLZzoSrJXrb9RXODG/0s16N6He+UWZBt5rCvC9BIw2L6xW6Jpiar8UjDl7il +RhRON/5JAgMBAAECggEAHWxlorbadvcziYlhDIUJsjdo7pkhOHtSOUDcBlEdvBBg +KgW8OjVrhxsk2L7a3JG2N+17N2c3UGi1yEk5QpwsEMynay2VRx0VUuApmEyzzwab +fJrWgaXeO0sJcCoSoKBc47PYbKGVeHSaeWgmfzfvQPXCmNb0tYGx2NK2Smoy/j1B +lXgODPkXHuzj0LOA3OkapgrxqHpN+kPjAfaY8vKYBQ8lbROT3kjgjqEzykC3bCzj +ZNZArGovBRAGr7dvjdh791g3hN2cAgIWhTg4zu8N6gf18G1l4bH8nmRzWT/z7eJi +QvmGjXVPUEpBcWRZuHms5cGcxb7V6smvuJp4v1n+rQKBgQDa1rqNwVlk1Jo0oT5U +KUyJwjaVXa3Foy5oR/T66UDIEBiMEonfI/miMlwXRXdhC1WQTeddk5vX+pn3ISZT +mN6zwz2NGE1i4GmOLIG9a9JkCSPffqDiwYFd2uhbTfKNehIHOC4Xdg/UGz+vOGFZ +MxYiSrytYK6svgHjHlFPp/uP9QKBgQDFA9wVmE76FqVC7crA7Djkyt4cRU5LEILO +qp4AxWE8HU/vlht4PhVA/dgMTNkVLiyrSgTGm15FQKZWe2FMVaAnRcmLy6bRpcAM +fP4HNtwjRWHx1Q4lMRZLrZPO0W8RXUqgMgGd3w1kyJK/C9wnD/01h+3lAnJ1cHlD +5jub6RDkhQKBgQCUciSKFCY3p6ATI23MWVd5+yxblfhSoKbSRj2AFsnC7Gg6XDj6 +DMVBqTee8ZhRVAbupGnVqFOG5o+ae/orqv8mocIW++1CrUftEXPQsls9UJXs/VDV +gL3olJ4ZkX5/SdcA3rMlZwjFsNY6XdxrTaQuDtR+J59Vvm45Sk+N4T1cIQKBgG9d +zSzP2eT4pBZ/QJtpbIe4PXGRo74+6RJV09bvvBU1JJh0K7b+sRj55QSe9B9K6Kky +wBxcex9+eghs2gVCabOJeXJyfiwIG9VzWk1Nr4aok8MWAlb3tni099ZzAOu55pND +cTKCgZm0327rD1ltal62Jb3MclL8by/4lz18s7XZAoGBANSv/AdjlJUQ++9I+P1+ +g7rgrfWKLyQ8FSljO7dAOWsDjrFHSi2f2HCh3URcKqzdjG+/iK+MyKUlaUZDLCzf +QNgI+7n5I/aHfhRWo7ytRPTd78Gyw/lDGW3Pz8MzXJ4pVDgr2UB7KN91/Rx9dJfN +3K04YR/TSpwB0Nug+5a1XuGh +-----END PRIVATE KEY----- diff --git a/tests/test_ssl.py b/tests/test_ssl.py new file mode 100644 index 0000000..115094f --- /dev/null +++ b/tests/test_ssl.py @@ -0,0 +1,269 @@ +import asyncio +import unittest +import os + +import asynctnt +from asynctnt.exceptions import SSLError +from asynctnt.instance import TarantoolSyncInstance +from tests import BaseTarantoolTestCase + +def is_test_ssl(): + env = os.getenv("TEST_TT_SSL") + if env: + env = env.upper() + return env == "1" or env == "TRUE" + return False + + +@unittest.skipIf(not is_test_ssl(), "TEST_TT_SSL is not set.") +class SSLTestCase(BaseTarantoolTestCase): + DO_CONNECT = False + + ssl_files_dir = os.path.join(os.getcwd(), 'tests', 'files', 'ssl') + cert_file = os.path.join(ssl_files_dir, "localhost.crt") + invalidhost_cert_file = os.path.join(ssl_files_dir, "invalidhost.crt") + key_file = os.path.join(ssl_files_dir, "localhost.key") + ca_file = os.path.join(ssl_files_dir, "ca.crt") + empty_file = os.path.join(ssl_files_dir, "empty") + invalid_file = "any_invalid_path" + + async def test__connect(self): + if self.in_docker: + self.skipTest('Skipping as running inside the docker') + return + + class SslTestSubcase: + def __init__(self, + name="", + expectSSLError=False, + expectTimeoutError=False, + server_transport=asynctnt.Transport.SSL, + server_key_file=None, + server_cert_file=None, + server_ca_file=None, + server_ciphers=None, + client_transport=asynctnt.Transport.SSL, + client_cert_file=None, + client_key_file=None, + client_ca_file=None, + client_ciphers=None): + self.name = name + self.expectSSLError = expectSSLError + self.expectTimeoutError = expectTimeoutError + self.server_transport = server_transport + self.server_key_file = server_key_file + self.server_cert_file = server_cert_file + self.server_ca_file = server_ca_file + self.server_ciphers = server_ciphers + self.client_transport = client_transport + self.client_cert_file = client_cert_file + self.client_key_file = client_key_file + self.client_ca_file = client_ca_file + self.client_ciphers = client_ciphers + + # Requirements from Tarantool Enterprise Edition manual: + # https://www.tarantool.io/en/enterprise_doc/security/#configuration + # + # For a server: + # ssl_key_file - mandatory + # ssl_cert_file - mandatory + # ssl_ca_file - optional + # ssl_ciphers - optional + # + # For a client: + # ssl_key_file - optional, mandatory if server.CaFile set + # ssl_cert_file - optional, mandatory if server.CaFile set + # ssl_ca_file - optional + # ssl_ciphers - optional + testcases = [ + SslTestSubcase( + name="no_ssl_server", + expectSSLError=True, + server_transport=asynctnt.Transport.DEFAULT), + SslTestSubcase( + name="key_crt_server", + server_key_file=self.key_file, + server_cert_file=self.cert_file), + SslTestSubcase( + name="no_ssl_client", + expectTimeoutError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + client_transport=asynctnt.Transport.DEFAULT), + SslTestSubcase( + name="key_crt_server_and_client", + server_key_file=self.key_file, + server_cert_file=self.cert_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file), + SslTestSubcase( + name="key_crt_ca_server", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file), + SslTestSubcase( + name="key_crt_ca_server_and_crt_client", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_cert_file=self.cert_file), + SslTestSubcase( + name="key_crt_ca_server_and_key_crt_client", + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file), + SslTestSubcase( + name="key_crt_ca_server_and_client", + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestSubcase( + name="key_invalidhost_crt_ca_server_and_key_crt_ca_client", + # A Tarantool implementation does not check hostname. It's + # the expected behavior. We don't do that too. + server_key_file=self.key_file, + server_cert_file=self.invalidhost_cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestSubcase( + name="key_crt_ca_server_and_client_invalid_crt", + expectSSLError=True, + client_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_key_file=self.key_file, + client_cert_file=self.invalid_file, + client_ca_file=self.ca_file), + SslTestSubcase( + name="key_crt_ca_server_and_client_invalid_key", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.invalid_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestSubcase( + name="key_crt_ca_server_and_client_invalid_ca", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.invalid_file), + SslTestSubcase( + name="key_crt_ca_server_and_client_empty_crt", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.empty_file, + client_ca_file=self.ca_file), + SslTestSubcase( + name="key_crt_ca_server_and_client_empty_key", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.empty_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestSubcase( + name="key_crt_ca_server_and_client_empty_ca", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.empty_file), + SslTestSubcase( + name="key_crt_ca_ciphers_server_and_key_crt_ca_client", + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_ciphers="ECDHE-RSA-AES256-GCM-SHA384", + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestSubcase( + name="key_crt_ca_ciphers_server_and_client", + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_ciphers="ECDHE-RSA-AES256-GCM-SHA384", + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file, + client_ciphers="ECDHE-RSA-AES256-GCM-SHA384"), + SslTestSubcase( + name="non_equal_ciphers", + expectSSLError=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_ciphers="ECDHE-RSA-AES256-GCM-SHA384", + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file, + client_ciphers="TLS_AES_128_GCM_SHA256"), + SslTestSubcase( + name="key_crt_ca_multiple_ciphers_server_and_client", + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_ciphers="ECDHE-RSA-AES256-GCM-SHA384:TLS_AES_128_GCM_SHA256", + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file, + client_ciphers="ECDHE-RSA-AES256-GCM-SHA384:TLS_AES_128_GCM_SHA256"), + ] + + for t in testcases: + with self.subTest(msg=t.name): + tnt = TarantoolSyncInstance( + port=TarantoolSyncInstance.get_random_port(), + transport=t.server_transport, + ssl_key_file=t.server_key_file, + ssl_cert_file=t.server_cert_file, + ssl_ca_file=t.server_ca_file, + ssl_ciphers=t.server_ciphers, + applua=self.read_applua(), + cleanup=self.TNT_CLEANUP, + ) + + tnt.start() + try: + conn = await asynctnt.connect(host=tnt.host, port=tnt.port, + transport=t.client_transport, + ssl_key_file=t.client_key_file, + ssl_cert_file=t.client_cert_file, + ssl_ca_file=t.client_ca_file, + ssl_ciphers=t.client_ciphers, + reconnect_timeout=0) + + tupl = [1, 'hello', 1, 4, 'what is up'] + await conn.insert(self.TESTER_SPACE_ID, tupl) + res = await conn.select(self.TESTER_SPACE_NAME, tupl[0:1]) + self.assertResponseEqual(res[0], tupl, 'Tuple ok') + except SSLError as e: + if not t.expectSSLError: + self.fail(e) + except asyncio.TimeoutError as e: + if not t.expectTimeoutError: + self.fail(e) + except Exception as e: + self.fail(e) + finally: + tnt.stop()