Repository: celery/py-amqp

Issue:
SSLTransport error: unexpected keyword argument 'ca_certs'
Using amqp version 5.0.1, I discovered an SSLTransport error. When I use amqp version 5.0.0b1, SSL works just fine.

Here's the error I get when using amqp==5.0.1:

`/usr/local/lib/python3.8/dist-packages/kombu/connection.py:283: in channel
    chan = self.transport.create_channel(self.connection)
/usr/local/lib/python3.8/dist-packages/kombu/connection.py:858: in connection
    return self._ensure_connection(
/usr/local/lib/python3.8/dist-packages/kombu/connection.py:435: in _ensure_connection
    return retry_over_time(
/usr/local/lib/python3.8/dist-packages/kombu/utils/functional.py:325: in retry_over_time
    return fun(*args, **kwargs)
/usr/local/lib/python3.8/dist-packages/kombu/connection.py:866: in _connection_factory
    self._connection = self._establish_connection()
/usr/local/lib/python3.8/dist-packages/kombu/connection.py:801: in _establish_connection
    conn = self.transport.establish_connection()
/usr/local/lib/python3.8/dist-packages/kombu/transport/pyamqp.py:128: in establish_connection
    conn.connect()
/usr/local/lib/python3.8/dist-packages/amqp/connection.py:314: in connect
    self.transport.connect()
/usr/local/lib/python3.8/dist-packages/amqp/transport.py:77: in connect
    self._init_socket(
/usr/local/lib/python3.8/dist-packages/amqp/transport.py:188: in _init_socket
    self._setup_transport()
/usr/local/lib/python3.8/dist-packages/amqp/transport.py:323: in _setup_transport
    self.sock = self._wrap_socket(self.sock, **self.sslopts)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <amqp.transport.SSLTransport object at 0x7f8a0083a370>
sock = <socket.socket fd=11, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 41008), raddr=('127.0.0.1', 5671)>
context = None
sslopts = {'ca_certs': '/home/cameron/Defense/tls-gen/basic/result/ca_certificate.pem', 'cert_reqs': <VerifyMode.CERT_REQUIRED: ...e/tls-gen/basic/result/client_certificate.pem', 'keyfile': '/home/cameron/Defense/tls-gen/basic/result/client_key.pem'}

    def _wrap_socket(self, sock, context=None, **sslopts):
        if context:
            return self._wrap_context(sock, sslopts, **context)
>       return self._wrap_socket_sni(sock, **sslopts)
E       TypeError: _wrap_socket_sni() got an unexpected keyword argument 'ca_certs'

/usr/local/lib/python3.8/dist-packages/amqp/transport.py:330: TypeError
----------------------------------------------------------- Captured stdout call -----------------------------------------------------------
2020-10-28 16:46:54:	INFO:	Consumer successfully connected to kombu server at localhost:5671
2020-10-28 16:46:54:	ERROR:	Failed to initialize rabbit consumer connection:
Traceback (most recent call last):
TypeError: _wrap_socket_sni() got an unexpected keyword argument 'ca_certs'`

Looking at `py-amqp/amqp/transport.py`, the parameters for the method `_wrap_socket_sni` for amqp==5.0.1, it doesn't include `ca_certs`. On amqp==5.0.0b1, the method _wrap_socket_sni` does include `ca_certs` as a parameter. Is this a bug or was this left out by accident?

Here are the links to amqp version 5.0.1 and 5.0.0b1 of `py-amqp/amqp/transport.py` with the line numbers highlighting the method:
amqp==5.0.1: https://github.com/celery/py-amqp/blob/93e4f3a2990f2ed1a6da861c99c7f0a3b0d32160/amqp/transport.py#L337
amqp==5.0.0b1: https://github.com/celery/py-amqp/blob/c5fe7daaf379cfbcccbe81fcd1ea12807274f8fb/amqp/transport.py#L339

Comments:

Patch:
diff --git a/amqp/transport.py b/amqp/transport.py
index 3eec88f..b183120 100644
--- a/amqp/transport.py
+++ b/amqp/transport.py
@@ -354,9 +354,9 @@ class SSLTransport(_AbstractTransport):
 
     def _wrap_socket_sni(self, sock, keyfile=None, certfile=None,
                          server_side=False, cert_reqs=ssl.CERT_NONE,
-                         do_handshake_on_connect=False,
+                         ca_certs=None, do_handshake_on_connect=False,
                          suppress_ragged_eofs=True, server_hostname=None,
-                         ssl_version=ssl.PROTOCOL_TLS):
+                         ciphers=None, ssl_version=ssl.PROTOCOL_TLS):
         """Socket wrap with SNI headers.
 
         stdlib `ssl.SSLContext.wrap_socket` method augmented with support for
@@ -373,6 +373,10 @@ class SSLTransport(_AbstractTransport):
         context = ssl.SSLContext(ssl_version)
         if certfile is not None:
             context.load_cert_chain(certfile, keyfile)
+        if ca_certs is not None:
+            context.load_verify_locations(ca_certs)
+        if ciphers:
+            context.set_ciphers(ciphers)
         if cert_reqs != ssl.CERT_NONE:
             context.check_hostname = True
         # Set SNI headers if supported


Test patch:
diff --git a/t/unit/test_transport.py b/t/unit/test_transport.py
index d94a520..5eec1ab 100644
--- a/t/unit/test_transport.py
+++ b/t/unit/test_transport.py
@@ -4,7 +4,7 @@ import re
 import socket
 import struct
 from struct import pack
-from unittest.mock import ANY, MagicMock, Mock, call, patch
+from unittest.mock import ANY, MagicMock, Mock, call, patch, sentinel
 
 import pytest
 
@@ -638,14 +638,136 @@ class test_SSLTransport:
             ctx.wrap_socket.assert_called_with(sock, f=1)
 
     def test_wrap_socket_sni(self):
+        # testing default values of _wrap_socket_sni()
         sock = Mock()
-        with patch('ssl.SSLContext.wrap_socket') as mock_ssl_wrap:
-            self.t._wrap_socket_sni(sock)
-            mock_ssl_wrap.assert_called_with(sock=sock,
-                                             server_side=False,
-                                             do_handshake_on_connect=False,
-                                             suppress_ragged_eofs=True,
-                                             server_hostname=None)
+        with patch(
+                'ssl.SSLContext.wrap_socket',
+                return_value=sentinel.WRAPPED_SOCKET) as mock_ssl_wrap:
+            ret = self.t._wrap_socket_sni(sock)
+
+        mock_ssl_wrap.assert_called_with(sock=sock,
+                                         server_side=False,
+                                         do_handshake_on_connect=False,
+                                         suppress_ragged_eofs=True,
+                                         server_hostname=None)
+
+        assert ret == sentinel.WRAPPED_SOCKET
+
+    def test_wrap_socket_sni_certfile(self):
+        # testing _wrap_socket_sni() with parameters certfile and keyfile
+        sock = Mock()
+        with patch(
+                'ssl.SSLContext.wrap_socket',
+                return_value=sentinel.WRAPPED_SOCKET) as mock_ssl_wrap, \
+             patch('ssl.SSLContext.load_cert_chain') as mock_load_cert_chain:
+            ret = self.t._wrap_socket_sni(
+                sock, keyfile=sentinel.KEYFILE, certfile=sentinel.CERTFILE)
+
+        mock_load_cert_chain.assert_called_with(
+            sentinel.CERTFILE, sentinel.KEYFILE)
+        mock_ssl_wrap.assert_called_with(sock=sock,
+                                         server_side=False,
+                                         do_handshake_on_connect=False,
+                                         suppress_ragged_eofs=True,
+                                         server_hostname=None)
+
+        assert ret == sentinel.WRAPPED_SOCKET
+
+    def test_wrap_socket_ca_certs(self):
+        # testing _wrap_socket_sni() with parameter ca_certs
+        sock = Mock()
+        with patch(
+            'ssl.SSLContext.wrap_socket',
+            return_value=sentinel.WRAPPED_SOCKET
+        ) as mock_ssl_wrap, patch(
+            'ssl.SSLContext.load_verify_locations'
+        ) as mock_load_verify_locations:
+            ret = self.t._wrap_socket_sni(sock, ca_certs=sentinel.CA_CERTS)
+
+        mock_load_verify_locations.assert_called_with(sentinel.CA_CERTS)
+        mock_ssl_wrap.assert_called_with(sock=sock,
+                                         server_side=False,
+                                         do_handshake_on_connect=False,
+                                         suppress_ragged_eofs=True,
+                                         server_hostname=None)
+
+        assert ret == sentinel.WRAPPED_SOCKET
+
+    def test_wrap_socket_ciphers(self):
+        # testing _wrap_socket_sni() with parameter ciphers
+        sock = Mock()
+        with patch(
+                'ssl.SSLContext.wrap_socket',
+                return_value=sentinel.WRAPPED_SOCKET) as mock_ssl_wrap, \
+             patch('ssl.SSLContext.set_ciphers') as mock_set_ciphers:
+            ret = self.t._wrap_socket_sni(sock, ciphers=sentinel.CIPHERS)
+
+        mock_set_ciphers.assert_called_with(sentinel.CIPHERS)
+        mock_ssl_wrap.assert_called_with(sock=sock,
+                                         server_side=False,
+                                         do_handshake_on_connect=False,
+                                         suppress_ragged_eofs=True,
+                                         server_hostname=None)
+        assert ret == sentinel.WRAPPED_SOCKET
+
+    def test_wrap_socket_sni_cert_reqs(self):
+        # testing _wrap_socket_sni() with parameter cert_reqs
+        sock = Mock()
+        with patch('ssl.SSLContext') as mock_ssl_context_class:
+            wrap_socket_method_mock = mock_ssl_context_class().wrap_socket
+            wrap_socket_method_mock.return_value = sentinel.WRAPPED_SOCKET
+            ret = self.t._wrap_socket_sni(sock, cert_reqs=sentinel.CERT_REQS)
+
+        wrap_socket_method_mock.assert_called_with(
+            sock=sock,
+            server_side=False,
+            do_handshake_on_connect=False,
+            suppress_ragged_eofs=True,
+            server_hostname=None
+        )
+        assert mock_ssl_context_class().check_hostname is True
+        assert ret == sentinel.WRAPPED_SOCKET
+
+    def test_wrap_socket_sni_setting_sni_header(self):
+        # testing _wrap_socket_sni() with setting SNI header
+        sock = Mock()
+        with patch('ssl.SSLContext') as mock_ssl_context_class, \
+             patch('ssl.HAS_SNI', new=True):
+            # SSL module supports SNI
+            wrap_socket_method_mock = mock_ssl_context_class().wrap_socket
+            wrap_socket_method_mock.return_value = sentinel.WRAPPED_SOCKET
+            ret = self.t._wrap_socket_sni(
+                sock, cert_reqs=sentinel.CERT_REQS,
+                server_hostname=sentinel.SERVER_HOSTNAME
+            )
+        wrap_socket_method_mock.assert_called_with(
+            sock=sock,
+            server_side=False,
+            do_handshake_on_connect=False,
+            suppress_ragged_eofs=True,
+            server_hostname=sentinel.SERVER_HOSTNAME
+        )
+        assert mock_ssl_context_class().verify_mode == sentinel.CERT_REQS
+        assert ret == sentinel.WRAPPED_SOCKET
+
+        with patch('ssl.SSLContext') as mock_ssl_context_class, \
+             patch('ssl.HAS_SNI', new=False):
+            # SSL module does not support SNI
+            wrap_socket_method_mock = mock_ssl_context_class().wrap_socket
+            wrap_socket_method_mock.return_value = sentinel.WRAPPED_SOCKET
+            ret = self.t._wrap_socket_sni(
+                sock, cert_reqs=sentinel.CERT_REQS,
+                server_hostname=sentinel.SERVER_HOSTNAME
+            )
+        wrap_socket_method_mock.assert_called_with(
+            sock=sock,
+            server_side=False,
+            do_handshake_on_connect=False,
+            suppress_ragged_eofs=True,
+            server_hostname=sentinel.SERVER_HOSTNAME
+        )
+        assert mock_ssl_context_class().verify_mode != sentinel.CERT_REQS
+        assert ret == sentinel.WRAPPED_SOCKET
 
     def test_shutdown_transport(self):
         self.t.sock = None