From 4c0287b8d75de219250686e6811781ca5652b2ff Mon Sep 17 00:00:00 2001 From: Walter BONETTI Date: Thu, 6 Jun 2024 11:15:38 -0400 Subject: [PATCH 1/2] Use custom paho-mqtt fork to handle pyOpenSSL context --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ca3f7df52..38cfb2a3b 100644 --- a/setup.py +++ b/setup.py @@ -77,7 +77,7 @@ "urllib3>=2.2.2,<3.0.0", # Actual project dependencies "deprecation>=2.1.0,<3.0.0", - "paho-mqtt>=1.6.1,<2.0.0", + "paho-mqtt @ git+https://github.com/IniterWorker/paho.mqtt.python.git@feature/pyopenssl-context", "requests>=2.32.3,<3.0.0", "requests-unixsocket2>=0.4.1", "janus", From 8fed99551b64711a939f290833991973109c2e51 Mon Sep 17 00:00:00 2001 From: Walter BONETTI Date: Thu, 6 Jun 2024 21:13:47 -0400 Subject: [PATCH 2/2] Add TPM support PyOpenSSL/PKCS#11 * AbstractIoTHubModuleClient * AbstractProvisioningDeviceClient --- .../azure/iot/device/common/http_transport.py | 9 +- .../azure/iot/device/common/mqtt_transport.py | 8 +- .../iot/device/common/pipeline/config.py | 6 +- .../common/pipeline/pipeline_stages_http.py | 1 + .../common/pipeline/pipeline_stages_mqtt.py | 1 + .../iot/device/iothub/abstract_clients.py | 129 ++++++++++++++++++ .../abstract_provisioning_device_client.py | 77 ++++++++++- setup.py | 1 + 8 files changed, 225 insertions(+), 7 deletions(-) diff --git a/azure-iot-device/azure/iot/device/common/http_transport.py b/azure-iot-device/azure/iot/device/common/http_transport.py index d09629f0e..d3a7fa8f6 100644 --- a/azure-iot-device/azure/iot/device/common/http_transport.py +++ b/azure-iot-device/azure/iot/device/common/http_transport.py @@ -27,6 +27,7 @@ def __init__( hostname, server_verification_cert=None, x509_cert=None, + ssl_context=None, cipher=None, proxy_options=None, ): @@ -37,11 +38,13 @@ def __init__( :param str server_verification_cert: Certificate which can be used to validate a server-side TLS connection (optional). :param str cipher: Cipher string in OpenSSL cipher list format (optional) :param x509_cert: Certificate which can be used to authenticate connection to a server in lieu of a password (optional). + :param ssl_context: Use a custom ssl_context (optional). :param proxy_options: Options for sending traffic through proxy servers. """ self._hostname = hostname self._server_verification_cert = server_verification_cert self._x509_cert = x509_cert + self._ssl_context = ssl_context self._cipher = cipher self._proxies = format_proxies(proxy_options) self._http_adapter = self._create_http_adapter() @@ -51,7 +54,11 @@ def _create_http_adapter(self): This method creates a custom HTTPAdapter for use with a requests library session. It will allow for use of a custom configured SSL context. """ - ssl_context = self._create_ssl_context() + + if self._ssl_context is None: + ssl_context = self._create_ssl_context() + else: + ssl_context = self._ssl_context class CustomSSLContextHTTPAdapter(requests.adapters.HTTPAdapter): def init_poolmanager(self, *args, **kwargs): diff --git a/azure-iot-device/azure/iot/device/common/mqtt_transport.py b/azure-iot-device/azure/iot/device/common/mqtt_transport.py index cf54a0416..aef73c2c2 100644 --- a/azure-iot-device/azure/iot/device/common/mqtt_transport.py +++ b/azure-iot-device/azure/iot/device/common/mqtt_transport.py @@ -94,6 +94,7 @@ def __init__( username, server_verification_cert=None, x509_cert=None, + ssl_context=None, websockets=False, cipher=None, proxy_options=None, @@ -106,6 +107,7 @@ def __init__( :param str username: Username for login to the remote broker. :param str server_verification_cert: Certificate which can be used to validate a server-side TLS connection (optional). :param x509_cert: Certificate which can be used to authenticate connection to a server in lieu of a password (optional). + :param ssl_context: Use a custom ssl_context (optional). :param bool websockets: Indicates whether or not to enable a websockets connection in the Transport. :param str cipher: Cipher string in OpenSSL cipher list format :param proxy_options: Options for sending traffic through proxy servers. @@ -116,6 +118,7 @@ def __init__( self._mqtt_client = None self._server_verification_cert = server_verification_cert self._x509_cert = x509_cert + self._ssl_context = ssl_context self._websockets = websockets self._cipher = cipher self._proxy_options = proxy_options @@ -165,7 +168,10 @@ def _create_mqtt_client(self): mqtt_client.enable_logger(logging.getLogger("paho")) # Configure TLS/SSL - ssl_context = self._create_ssl_context() + if self._ssl_context is None: + ssl_context = self._create_ssl_context() + else: + ssl_context = self._ssl_context mqtt_client.tls_set_context(context=ssl_context) # Set event handlers. Use weak references back into this object to prevent leaks diff --git a/azure-iot-device/azure/iot/device/common/pipeline/config.py b/azure-iot-device/azure/iot/device/common/pipeline/config.py index 81a507b6e..675beefef 100644 --- a/azure-iot-device/azure/iot/device/common/pipeline/config.py +++ b/azure-iot-device/azure/iot/device/common/pipeline/config.py @@ -27,6 +27,7 @@ def __init__( gateway_hostname=None, sastoken=None, x509=None, + ssl_context=None, server_verification_cert=None, websockets=False, cipher="", @@ -69,8 +70,9 @@ def __init__( # Auth self.sastoken = sastoken self.x509 = x509 - if (not sastoken and not x509) or (sastoken and x509): - raise ValueError("One of either 'sastoken' or 'x509' must be provided") + self.ssl_context = ssl_context + if (not sastoken and not x509 and not ssl_context) or (sastoken and x509): + raise ValueError("One of either 'sastoken' or 'x509', 'ssl_context' must be provided") self.server_verification_cert = server_verification_cert self.websockets = websockets self.cipher = self._sanitize_cipher(cipher) diff --git a/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_http.py b/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_http.py index 79cfbfbbd..22440d3ff 100644 --- a/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_http.py +++ b/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_http.py @@ -58,6 +58,7 @@ def _run_op(self, op): hostname=hostname, server_verification_cert=self.nucleus.pipeline_configuration.server_verification_cert, x509_cert=self.nucleus.pipeline_configuration.x509, + ssl_context=self.nucleus.pipeline_configuration.ssl_context, cipher=self.nucleus.pipeline_configuration.cipher, proxy_options=self.nucleus.pipeline_configuration.proxy_options, ) diff --git a/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_mqtt.py b/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_mqtt.py index 236ca244b..779d8d695 100644 --- a/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_mqtt.py +++ b/azure-iot-device/azure/iot/device/common/pipeline/pipeline_stages_mqtt.py @@ -153,6 +153,7 @@ def _run_op(self, op): username=op.username, server_verification_cert=self.nucleus.pipeline_configuration.server_verification_cert, x509_cert=self.nucleus.pipeline_configuration.x509, + ssl_context=self.nucleus.pipeline_configuration.ssl_context, websockets=self.nucleus.pipeline_configuration.websockets, cipher=self.nucleus.pipeline_configuration.cipher, proxy_options=self.nucleus.pipeline_configuration.proxy_options, diff --git a/azure-iot-device/azure/iot/device/iothub/abstract_clients.py b/azure-iot-device/azure/iot/device/iothub/abstract_clients.py index a867dd614..7761dd43f 100644 --- a/azure-iot-device/azure/iot/device/iothub/abstract_clients.py +++ b/azure-iot-device/azure/iot/device/iothub/abstract_clients.py @@ -12,6 +12,9 @@ import os import io import time +import ssl +import OpenSSL + from . import pipeline from .pipeline import constant as pipeline_constant from azure.iot.device.common.auth import connection_string as cs @@ -615,6 +618,66 @@ def create_from_x509_certificate( return cls(mqtt_pipeline, http_pipeline) + @classmethod + def create_from_ssl_context( + cls, + ssl_context: ssl.SSLContext | OpenSSL.SSL.Context, + hostname: str, + device_id: str, + **kwargs, + ) -> Self: + """ + Instantiate a client using X509 certificate authentication. + + :param str hostname: Host running the IotHub. + Can be found in the Azure portal in the Overview tab as the string hostname. + :param ssl_context: Custom ssl.SSLContext + :param str device_id: The ID used to uniquely identify a device in the IoTHub + + :param str server_verification_cert: Configuration Option. The trusted certificate chain. + Necessary when using connecting to an endpoint which has a non-standard root of trust, + such as a protocol gateway. + :param str gateway_hostname: Configuration Option. The gateway hostname for the gateway + device. + :param bool websockets: Configuration Option. Default is False. Set to true if using MQTT + over websockets. + :param str product_info: Configuration Option. Default is empty string. The string contains + arbitrary product info which is appended to the user agent string. + :param proxy_options: Options for sending traffic through proxy servers. + :type proxy_options: :class:`azure.iot.device.ProxyOptions` + :param int keep_alive: Maximum period in seconds between communications with the + broker. If no other messages are being exchanged, this controls the + rate at which the client will send ping messages to the broker. + If not provided default value of 60 secs will be used. + :param bool auto_connect: Automatically connect the client to IoTHub when a method is + invoked which requires a connection to be established. (Default: True) + :param bool connection_retry: Attempt to re-establish a dropped connection (Default: True) + :param int connection_retry_interval: Interval, in seconds, between attempts to + re-establish a dropped connection (Default: 10) + :param bool ensure_desired_properties: Ensure the most recent desired properties patch has + been received upon re-connections (Default:True) + + :raises: TypeError if given an unsupported parameter. + + :returns: An instance of an IoTHub client that uses an X509 certificate for authentication. + """ + # Ensure no invalid kwargs were passed by the user + excluded_kwargs = ["sastoken_ttl"] + _validate_kwargs(exclude=excluded_kwargs, **kwargs) + + # Pipeline Config setup + config_kwargs = _get_config_kwargs(**kwargs) + pipeline_configuration = pipeline.IoTHubPipelineConfig( + device_id=device_id, hostname=hostname, ssl_context=ssl_context, **config_kwargs + ) + pipeline_configuration.blob_upload = True # Blob Upload is a feature on Device Clients + + # Pipeline setup + http_pipeline = pipeline.HTTPPipeline(pipeline_configuration) + mqtt_pipeline = pipeline.MQTTPipeline(pipeline_configuration) + + return cls(mqtt_pipeline, http_pipeline) + @classmethod def create_from_symmetric_key( cls, symmetric_key: str, hostname: str, device_id: str, **kwargs @@ -915,6 +978,72 @@ def create_from_x509_certificate( mqtt_pipeline = pipeline.MQTTPipeline(pipeline_configuration) return cls(mqtt_pipeline, http_pipeline) + @classmethod + def create_from_ssl_context( + cls, + ssl_context: ssl.SSLContext | OpenSSL.SSL.Context, + hostname: str, + device_id: str, + module_id: str, + **kwargs, + ) -> Self: + """ + Instantiate a client using X509 certificate authentication. + + :param str hostname: Host running the IotHub. + Can be found in the Azure portal in the Overview tab as the string hostname. + :param ssl_context: Custom ssl.SSLContext + :param str device_id: The ID used to uniquely identify a device in the IoTHub + :param str module_id: The ID used to uniquely identify a module on a device on the IoTHub. + + :param str server_verification_cert: Configuration Option. The trusted certificate chain. + Necessary when using connecting to an endpoint which has a non-standard root of trust, + such as a protocol gateway. + :param str gateway_hostname: Configuration Option. The gateway hostname for the gateway + device. + :param bool websockets: Configuration Option. Default is False. Set to true if using MQTT + over websockets. + :param str product_info: Configuration Option. Default is empty string. The string contains + arbitrary product info which is appended to the user agent string. + :param proxy_options: Options for sending traffic through proxy servers. + :type proxy_options: :class:`azure.iot.device.ProxyOptions` + :param int keep_alive: Maximum period in seconds between communications with the + broker. If no other messages are being exchanged, this controls the + rate at which the client will send ping messages to the broker. + If not provided default value of 60 secs will be used. + :param bool auto_connect: Automatically connect the client to IoTHub when a method is + invoked which requires a connection to be established. (Default: True) + :param bool connection_retry: Attempt to re-establish a dropped connection (Default: True) + :param int connection_retry_interval: Interval, in seconds, between attempts to + re-establish a dropped connection (Default: 10) + :param bool ensure_desired_properties: Ensure the most recent desired properties patch has + been received upon re-connections (Default:True) + + :raises: TypeError if given an unsupported parameter. + + :returns: An instance of an IoTHub client that uses an X509 certificate for authentication. + """ + # Ensure no invalid kwargs were passed by the user + excluded_kwargs = ["sastoken_ttl"] + _validate_kwargs(exclude=excluded_kwargs, **kwargs) + + # Pipeline Config setup + config_kwargs = _get_config_kwargs(**kwargs) + pipeline_configuration = pipeline.IoTHubPipelineConfig( + device_id=device_id, + module_id=module_id, + hostname=hostname, + ssl_context=ssl_context, + **config_kwargs, + ) + pipeline_configuration.blob_upload = True # Blob Upload is a feature on Device Clients + + # Pipeline setup + http_pipeline = pipeline.HTTPPipeline(pipeline_configuration) + mqtt_pipeline = pipeline.MQTTPipeline(pipeline_configuration) + + return cls(mqtt_pipeline, http_pipeline) + @abc.abstractmethod def send_message_to_output(self, message: Union[Message, str], output_name: str) -> None: pass diff --git a/azure-iot-device/azure/iot/device/provisioning/abstract_provisioning_device_client.py b/azure-iot-device/azure/iot/device/provisioning/abstract_provisioning_device_client.py index 6ce83d23e..5480cba25 100644 --- a/azure-iot-device/azure/iot/device/provisioning/abstract_provisioning_device_client.py +++ b/azure-iot-device/azure/iot/device/provisioning/abstract_provisioning_device_client.py @@ -10,6 +10,8 @@ import abc import logging +import ssl +import OpenSSL from typing_extensions import Self from typing import Any, Dict, List, Optional from azure.iot.device.provisioning import pipeline @@ -95,7 +97,12 @@ def __init__(self, pipeline: MQTTPipeline): @classmethod def create_from_symmetric_key( - cls, provisioning_host: str, registration_id: str, id_scope: str, symmetric_key: str, **kwargs + cls, + provisioning_host: str, + registration_id: str, + id_scope: str, + symmetric_key: str, + **kwargs, ) -> Self: """ Create a client which can be used to run the registration of a device with provisioning service @@ -159,7 +166,7 @@ def create_from_symmetric_key( registration_id=registration_id, id_scope=id_scope, sastoken=sastoken, - **config_kwargs + **config_kwargs, ) # Pipeline setup @@ -221,7 +228,71 @@ def create_from_x509_certificate( registration_id=registration_id, id_scope=id_scope, x509=x509, - **config_kwargs + **config_kwargs, + ) + + # Pipeline setup + mqtt_provisioning_pipeline = pipeline.MQTTPipeline(pipeline_configuration) + + return cls(mqtt_provisioning_pipeline) + + @classmethod + def create_from_ssl_context( + cls, + provisioning_host: str, + registration_id: str, + id_scope: str, + ssl_context: ssl.SSLContext | OpenSSL.SSL.Context, + **kwargs, + ) -> Self: + """ + Create a client which can be used to run the registration of a device with + provisioning service using X509 certificate authentication. + + :param str provisioning_host: Host running the Device Provisioning Service. Can be found in + the Azure portal in the Overview tab as the string Global device endpoint. + :param str registration_id: The registration ID used to uniquely identify a device in the + Device Provisioning Service. The registration ID is alphanumeric, lowercase string + and may contain hyphens. + :param str id_scope: The ID scope is used to uniquely identify the specific + provisioning service the device will register through. The ID scope is assigned to a + Device Provisioning Service when it is created by the user and is generated by the + service and is immutable, guaranteeing uniqueness. + :param ssl_context: Custom ssl.SSLContext + + :param str server_verification_cert: Configuration Option. The trusted certificate chain. + Necessary when using connecting to an endpoint which has a non-standard root of trust, + such as a protocol gateway. + :param str gateway_hostname: Configuration Option. The gateway hostname for the gateway + device. + :param bool websockets: Configuration Option. Default is False. Set to true if using MQTT + over websockets. + :param cipher: Configuration Option. Cipher suite(s) for TLS/SSL, as a string in + "OpenSSL cipher list format" or as a list of cipher suite strings. + :type cipher: str or list(str) + :param proxy_options: Options for sending traffic through proxy servers. + :type proxy_options: :class:`azure.iot.device.ProxyOptions` + :param int keepalive: Maximum period in seconds between communications with the + broker. If no other messages are being exchanged, this controls the + rate at which the client will send ping messages to the broker. + If not provided default value of 60 secs will be used. + :raises: TypeError if given an unrecognized parameter. + + :returns: A ProvisioningDeviceClient which can register via X509 client certificates. + """ + validate_registration_id(registration_id) + # Ensure no invalid kwargs were passed by the user + excluded_kwargs = ["sastoken_ttl"] + _validate_kwargs(exclude=excluded_kwargs, **kwargs) + + # Pipeline Config setup + config_kwargs = _get_config_kwargs(**kwargs) + pipeline_configuration = pipeline.ProvisioningPipelineConfig( + hostname=provisioning_host, + registration_id=registration_id, + id_scope=id_scope, + ssl_context=ssl_context, + **config_kwargs, ) # Pipeline setup diff --git a/setup.py b/setup.py index 38cfb2a3b..33c823019 100644 --- a/setup.py +++ b/setup.py @@ -80,6 +80,7 @@ "paho-mqtt @ git+https://github.com/IniterWorker/paho.mqtt.python.git@feature/pyopenssl-context", "requests>=2.32.3,<3.0.0", "requests-unixsocket2>=0.4.1", + "pyOpenSSL>=23.2.0", "janus", "PySocks", "typing_extensions",