Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8359207
Partial work for CSR over IoT Hub
ewertons Feb 6, 2026
3c65f26
Fix documentation
Feb 6, 2026
f4f2115
Implement async version of send_certificate_signing_request
Feb 7, 2026
bbbea0c
Delete CSR sample for DPS-only, under azure-iot-device
Feb 7, 2026
8aab8d4
Several little fixes
Feb 7, 2026
3b89ed9
Partial changes to implement CSR/IoT
Feb 9, 2026
f13c63b
Add stages, ops, events for CertificateSigningRequest
Feb 10, 2026
8cdb873
Complete CSR functionality
Feb 10, 2026
c5876b6
Update code documentation for CSR
Feb 10, 2026
526fd2c
Cleanup certificate_issuance.py (#1)
Feb 10, 2026
861ccd4
Add reconnection to certificate_issuance.py, cleanup
Feb 10, 2026
340d97a
Update CSR sample and its documentation
Feb 11, 2026
e63dc1d
Fix use of iothub_csr_data variable
Feb 11, 2026
76cea5c
Add cert mgmt pipeline yaml
ewertons Mar 5, 2026
401577b
Rename certificate_management.md -> readme.md
ewertons Mar 6, 2026
07179fa
Address CR comments (change env var name)
ewertons Mar 7, 2026
7b7b5f3
Add credentialPolicyName on enrollment creation requests
ewertons Mar 7, 2026
158ee6e
Remove previous ClientCertificateIssuancePolicy construct
ewertons Mar 7, 2026
e1beadc
Add env var generation cmdlet, re-enable build and tests
ewertons Mar 7, 2026
f831984
Pass private key password only if defined as a non-empty string
ewertons Mar 8, 2026
7da5ed4
Adjust Dps service API version for cert mgmt (2021-10-01)
ewertons Mar 8, 2026
b578023
Adjust Dps service API version for cert mgmt (try 2025-07-01-preview)
ewertons Mar 9, 2026
a171acb
Add e2e tests for certificate signing request
ewertons Mar 10, 2026
bde4ea9
device_client.send_certificate_signing_request to take args directly
ewertons Mar 10, 2026
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
2 changes: 1 addition & 1 deletion azure-iot-device/azure/iot/device/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
VERSION = "2.15.0rc1"
IOTHUB_IDENTIFIER = "azure-iot-device-iothub-py"
PROVISIONING_IDENTIFIER = "azure-iot-device-provisioning-py"
IOTHUB_API_VERSION = "2019-10-01"
IOTHUB_API_VERSION = "2025-08-01-preview"
PROVISIONING_API_VERSION = "2025-07-01-preview"
SECURITY_MESSAGE_INTERFACE_ID = "urn:azureiot:Security:SecurityAgent:1"
TELEMETRY_MESSAGE_SIZE_LIMIT = 262144
Expand Down
7 changes: 7 additions & 0 deletions azure-iot-device/azure/iot/device/iothub/abstract_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from azure.iot.device.common.auth import sastoken as st
from azure.iot.device.iothub import client_event
from azure.iot.device.iothub.models import Message, MethodRequest, MethodResponse
from azure.iot.device.iothub.models import CertificateSigningResponse
from azure.iot.device.common.models import X509
from azure.iot.device import exceptions
from azure.iot.device.common import auth, handle_exceptions
Expand Down Expand Up @@ -451,6 +452,12 @@ def patch_twin_reported_properties(self, reported_properties_patch: TwinPatch) -
def receive_twin_desired_properties_patch(self) -> TwinPatch:
pass

@abc.abstractmethod
def send_certificate_signing_request(
self, csr: str, replace: str
) -> CertificateSigningResponse:
pass

@property
def connected(self) -> bool:
"""
Expand Down
49 changes: 48 additions & 1 deletion azure-iot-device/azure/iot/device/iothub/aio/async_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
AbstractIoTHubDeviceClient,
AbstractIoTHubModuleClient,
)
from azure.iot.device.iothub.models import Message, MethodRequest, MethodResponse
from azure.iot.device.iothub.models import (
Message,
MethodRequest,
MethodResponse,
CertificateSigningResponse,
)
from azure.iot.device.iothub.models.certificate_signing_request import CertificateSigningRequest
from azure.iot.device.iothub.pipeline import constant
from azure.iot.device.iothub.pipeline import exceptions as pipeline_exceptions
from azure.iot.device import exceptions
Expand Down Expand Up @@ -520,6 +526,47 @@ async def receive_twin_desired_properties_patch(self) -> TwinPatch:
logger.info("twin patch received")
return patch

async def send_certificate_signing_request(
self, csr: str, replace: str
) -> CertificateSigningResponse:
"""
Sends a Certificate Signing Request to Azure IoT Hub.

:param str csr: The base64-encoded certificate signing request.
Copy link
Member

Choose a reason for hiding this comment

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

Is the string really the request? And I don't quite get what replace is. These parameters are much less explicit in name and documentation than any other API.

:param str replace: Replace any active credential operation for this device.
:returns: The certificate issued by Azure IoT Hub for the certificate signing request provided.
:rtype: CertificateSigningResponse

:raises: :class:`azure.iot.device.exceptions.CredentialError` if credentials are invalid
and a connection cannot be established.
:raises: :class:`azure.iot.device.exceptions.ConnectionFailedError` if a establishing a
connection results in failure.
:raises: :class:`azure.iot.device.exceptions.ConnectionDroppedError` if connection is lost
during execution.
:raises: :class:`azure.iot.device.exceptions.OperationTimeout` if connection attempt
times out
:raises: :class:`azure.iot.device.exceptions.NoConnectionError` if the client is not
connected (and there is no auto-connect enabled)
:raises: :class:`azure.iot.device.exceptions.ClientError` if there is an unexpected failure
during execution.
"""
logger.info("Sending certificate signing request")

if not self._mqtt_pipeline.feature_enabled[constant.CSR]:
await self._enable_feature(constant.CSR)

request = CertificateSigningRequest(csr=csr, replace=replace)

send_certificate_signing_request_async = async_adapter.emulate_async(
self._mqtt_pipeline.send_certificate_signing_request
)

callback = async_adapter.AwaitableCallback(return_arg_name="response")
await send_certificate_signing_request_async(request=request, callback=callback)
certificate_signing_response = await handle_result(callback)
logger.info("Received certificate signing response")
return certificate_signing_response


class IoTHubDeviceClient(GenericIoTHubClient, AbstractIoTHubDeviceClient):
"""An asynchronous device client that connects to an Azure IoT Hub instance."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@

from .message import Message # noqa: F401
from .methods import MethodRequest, MethodResponse # noqa: F401
from .certificate_signing_request import CertificateSigningResponse # noqa: F401
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
"""This module contains a class representing messages that are sent or received.
"""


class CertificateSigningRequest(object):
"""Represents a Certificate Signing Request message to Azure IoT Hub

:ivar csr: The base64-encoded certificate signing request.
:ivar replace: Replace any active credential operation for this device.
"""

def __init__(self, csr, replace):
"""
Initializer for CertificateSigningRequest

:param str csr: The base64-encoded certificate signing request.
:param str replace: Replace any active credential operation for this device.
"""
self.id = None # The device id, filled internally by the pipeline configuration.
self.csr = csr
self.replace = replace

def __str__(self):
return str(self.csr)

def to_dict(obj):
data = obj.__dict__.copy()
return data


class CertificateSigningResponse(object):
"""Represents a Certificate Signing Response message from Azure IoT Hub

:ivar status_code: The result code for the certificate signing request.
:ivar certificates: An array with the base64-encoded issued certificate chain (leaf, intermediate and root, in this order).
"""

def __init__(self, status_code, certificates):
"""
Initializer for CertificateSigningResponse

:param status_code: The result code for the certificate signing request.
:param certificates: An array with the base64-encoded issued certificate chain (leaf, intermediate and root, in this order).
"""
self.status_code = status_code
self.certificates = certificates
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

# Feature names
C2D_MSG = "c2d"
CSR = "csr"
INPUT_MSG = "input"
METHODS = "methods"
TWIN = "twin"
Expand Down
45 changes: 45 additions & 0 deletions azure-iot-device/azure/iot/device/iothub/pipeline/mqtt_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(self, pipeline_configuration):

self.feature_enabled = {
constant.C2D_MSG: False,
constant.CSR: False,
constant.INPUT_MSG: False,
constant.METHODS: False,
constant.TWIN: False,
Expand Down Expand Up @@ -82,6 +83,11 @@ def __init__(self, pipeline_configuration):
#
.append_stage(pipeline_stages_base.CoordinateRequestAndResponseStage())
#
# CertificateSigningRequestResponseStage needs to be before IoTHubMQTTTranslationStage
# because that stage operates on ops that CertificateSigningRequestResponseStage produces
#
.append_stage(pipeline_stages_iothub.CertificateSigningRequestResponseStage())
#
# IoTHubMQTTTranslationStage comes here because this is the point where we can translate
# all operations directly into MQTT. After this stage, only pipeline_stages_base stages
# are allowed because IoTHubMQTTTranslationStage removes all the IoTHub-ness from the ops
Expand Down Expand Up @@ -482,6 +488,45 @@ def on_complete(op, error):
)
)

def send_certificate_signing_request(self, request, callback):
"""
Send a certificate signing request to the service.

:param request: the certificate signing request to send
:param callback: callback which is called when the request attempt is complete.

:raises: :class:`azure.iot.device.iothub.pipeline.exceptions.PipelineNotRunning` if the
pipeline has previously been shut down

The following exceptions are not "raised", but rather returned via the "error" parameter
when invoking "callback":

:raises: :class:`azure.iot.device.iothub.pipeline.exceptions.NoConnectionError`
:raises: :class:`azure.iot.device.iothub.pipeline.exceptions.ProtocolClientError`

The following exceptions can be returned via the "error" parameter only if auto-connect
is enabled in the pipeline configuration:

:raises: :class:`azure.iot.device.iothub.pipeline.exceptions.ConnectionFailedError`
:raises: :class:`azure.iot.device.iothub.pipeline.exceptions.ConnectionDroppedError`
:raises: :class:`azure.iot.device.iothub.pipeline.exceptions.UnauthorizedError`
:raises: :class:`azure.iot.device.iothub.pipeline.exceptions.OperationTimeout`
"""
self._verify_running()
logger.debug("Starting CertificateSigningRequestOperation on the pipeline")

def on_complete(op, error):
if error:
callback(error=error, response=None)
else:
callback(response=op.response)

self._pipeline.run_op(
pipeline_ops_iothub.CertificateSigningRequestOperation(
request=request, callback=on_complete
)
)

# NOTE: Currently, this operation will retry itself indefinitely in the case of timeout
def enable_feature(self, feature_name, callback):
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,71 @@ def is_method_topic(topic):
return False


def get_certificate_signing_response_topic_for_subscribe():
Copy link
Member

Choose a reason for hiding this comment

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

Style nit: this module is organized by subscribe/publish/is_x, not by feature grouping, so these functions should be interspersed as per the existing pattern.

"""
:return: The topic for Certificate Signing Response messages. It is of the format
"$iothub/credentials/res/#"
"""
return "$iothub/credentials/res/#"


def get_certificate_signing_request_topic_for_publish(request_id):
"""
:return: The topic for Certificate Signing Request messages. It is of the format
"$iothub/credentials/POST/issueCertificate/?$rid={request_id}"
"""
return "$iothub/credentials/POST/issueCertificate/?$rid={request_id}".format(
request_id=request_id
)


def is_certificate_signing_response_topic(topic):
"""
Topics for certificate signing responses are of the following format:
"$iothub/credentials/res/{status}/?$rid={request_id}"

:param str topic: The topic string.
"""
return topic.startswith("$iothub/credentials/res/")


def get_certificate_signing_response_request_id_from_topic(topic):
"""
Extract the request id from the certificate signing response topic.
Topics for certificate signing responses are of the following format:
"$iothub/credentials/res/{status}/?$rid={request_id}"

:param str topic: The topic string
"""
if is_certificate_signing_response_topic(topic):
parts = topic.split("/")
if len(parts) == 5:
properties = _extract_properties(topic.split("?")[1])
return properties["rid"]
else:
raise ValueError("topic has incorrect format")
else:
raise ValueError("topic has incorrect format")


def get_certificate_signing_response_status_code_from_topic(topic):
"""
Extract the status-code from the certificate signing response topic.
Topics for certificate signing responses are of the following format:
"$iothub/credentials/res/{status-code}/?$rid={request_id}"

:param str topic: The topic string
"""
if is_certificate_signing_response_topic(topic):
parts = topic.split("/")
if len(parts) == 5:
return urllib.parse.unquote(parts[3])
else:
raise ValueError("topic has incorrect format")
else:
raise ValueError("topic has incorrect format")


def is_twin_response_topic(topic):
"""Topics for twin responses are of the following format:
$iothub/twin/res/{status}/?$rid={rid}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,16 @@ class TwinDesiredPropertiesPatchEvent(PipelineEvent):
def __init__(self, patch):
super().__init__()
self.patch = patch


class CertificateSigningResponseEvent(PipelineEvent):
"""
A PipelineEvent object which represents an incoming response for a certificate signing request. This
object is created by a Azure IoT Hub converter stage based on a protocol-specific event.
"""

def __init__(self, request_id, status_code, payload):
super().__init__()
self.request_id = request_id
self.status_code = status_code
self.payload = payload
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,26 @@ def __init__(self, patch, callback):
"""
super().__init__(callback=callback)
self.patch = patch


class CertificateSigningRequestOperation(PipelineOperation):
"""
A PipelineOperation object which contains arguments used to send a certificate signing request
to the Azure IoT Hub.
"""

def __init__(self, request, callback):
"""
Initializer for CertificateSigningRequestOperation object

:param request: The certificate signing request to send to the service. User-provided.
:type request: CertificateSigningRequest
:param request_id: The id of the certificate signing request to send to the service. Internally-generated.
:type request_id: str
:param response: The certificate signing response received by the service. Generated by this SDK.
:type response: CertificateSigningResponse
"""
super().__init__(callback=callback)
self.request = request
self.request_id = None
self.response = None
Loading