This article shows how to provision Azure IoT hub devices using Azure IoT hub device provisioning services (DPS) and ASP.NET Core. The devices are setup using chained certificates created using .NET Core and managed in the web application. The data is persisted in a database using EF Core and the certificates are generated using the CertificateManager Nuget package.
Code https//github.com/damienbod/AzureIoTHubDps
Setup
To setup a new Azure IoT Hub DPS, enrollment group and devices, the web application creates a new certificate using an ECDsa private key and the .NET Core APIs. The data is stored in two pem files, one for the public certificate and one for the private key. The pem public certificate file is downloaded from the web application and uploaded to the certificates blade in Azure IoT Hub DPS. The web application persists the data to a database using EF Core and SQL. A new certificate is created from the DPS root certificate and used to create a DPS enrollment group. The certificates are chained from the original DPS certificate. New devices are registered and created using the enrollment group. Another new device certificate chained from the enrollment group certificate is created per device and used in the DPS. The Azure IoT Hub DPS creates a new IoT Hub device using the linked IoT Hubs. Once the IoT hub is running, the private key from the device certificate is used to authenticate the device and send data to the server.
When the ASP.NET Core web application is started, users can create new certificates, enrollment groups and add devices to the groups. I plan to extend the web application to add devices, delete devices, and delete groups. I plan to add authorization for the different user types and better paging for the different UIs. At present all certificates use ECDsa private keys but this can easily be changed to other types. This depends on the type of root certificate used.
The application is secured using Microsoft.Identity.Web and requires an authenticated user. This can be setup in the program file or in the startup extensions. I use EnableTokenAcquisitionToCallDownstreamApi to force the OpenID Connect code flow. The configuration is read from the default AzureAd app.settings and the whole application is required to be authenticated. When the enable and disable flows are added, I will add different users with different authorization levels.
builder.Services.AddDistributedMemoryCache();
builder.Services.AddAuthentication(
OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(
builder.Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi()
.AddDistributedTokenCaches();
Create an Azure IoT Hub DPS certificate
The web application is used to create devices using certificates and DPS enrollment groups. The DpsCertificateProvider class is used to create the root self signed certificate for the DPS enrollment groups. The NewRootCertificate from the CertificateManager Nuget package is used to create the certificate using an ECDsa private key. This package wraps the default .NET APIs for creating certificates and adds a layer of abstraction. You could just use the lower level APIs directly. The certificate is exported to two separate pem files and persisted to the database.
public class DpsCertificateProvider
{
private readonly CreateCertificatesClientServerAuth _createCertsService;
private readonly ImportExportCertificate _iec;
private readonly DpsDbContext _dpsDbContext;
public DpsCertificateProvider(CreateCertificatesClientServerAuth ccs,
ImportExportCertificate importExportCertificate,
DpsDbContext dpsDbContext)
{
_createCertsService = ccs;
_iec = importExportCertificate;
_dpsDbContext = dpsDbContext;
}
public async Task<(string PublicPem, int Id)> CreateCertificateForDpsAsync(string certName)
{
var certificateDps = _createCertsService.NewRootCertificate(
new DistinguishedName { CommonName = certName, Country = "CH" },
new ValidityPeriod { ValidFrom = DateTime.UtcNow, ValidTo = DateTime.UtcNow.AddYears(50) },
3, certName);
var publicKeyPem = _iec.PemExportPublicKeyCertificate(certificateDps);
string pemPrivateKey = string.Empty;
using (ECDsa? ecdsa = certificateDps.GetECDsaPrivateKey())
{
pemPrivateKey = ecdsa!.ExportECPrivateKeyPem();
FileProvider.WriteToDisk($"{certName}-private.pem", pemPrivateKey);
}
var item = new DpsCertificate
{
Name = certName,
PemPrivateKey = pemPrivateKey,
PemPublicKey = publicKeyPem
};
_dpsDbContext.DpsCertificates.Add(item);
await _dpsDbContext.SaveChangesAsync();
return (publicKeyPem, item.Id);
}
public async Task<List<DpsCertificate>> GetDpsCertificatesAsync()
{
return await _dpsDbContext.DpsCertificates.ToListAsync();
}
public async Task<DpsCertificate?> GetDpsCertificateAsync(int id)
{
return await _dpsDbContext.DpsCertificates.FirstOrDefaultAsync(item => item.Id == id);
}
}
Once the root certificate is created, you can download the public pem file from the web application and upload it to the Azure IoT Hub DPS portal. This needs to be verified. You could also use a CA created certificate for this, if it is possible to create child chained certificates. The enrollment groups are created from this root certificate.
Create an Azure IoT Hub DPS enrollment group
Devices can be created in different ways in the Azure IoT Hub. We use a DPS enrollment group with certificates to create the Azure IoT devices. The DpsEnrollmentGroupProvider is used to create the enrollment group certificate. This uses the root certificate created in the previous step and chains the new group certificate from this. The enrollment group is used to add devices. Default values are defined for the enrollment group and the pem files are saved to the database. The root certificate is read from the database and the chained enrollment group certificate uses an ECDsa private key like the root self signed certificate.
The CreateEnrollmentGroup method is used to set the initial values of the IoT Hub Device. The ProvisioningStatus is set to enabled. This means when the device is registered, it will be enabled to send messages. You could also set this to disabled and enable it after when the device gets used by an end client for the first time. A MAC or a serial code from the device hardware could be used to enable the IoT Hub device. By waiting till the device is started by the end client, you could choose a IoT Hub optimized for this client.
public class DpsEnrollmentGroupProvider
{
private IConfiguration Configuration { get;set;}
private readonly ILogger<DpsEnrollmentGroupProvider> _logger;
private readonly DpsDbContext _dpsDbContext;
private readonly ImportExportCertificate _iec;
private readonly CreateCertificatesClientServerAuth _createCertsService;
private readonly ProvisioningServiceClient _provisioningServiceClient;
public DpsEnrollmentGroupProvider(IConfiguration config, ILoggerFactory loggerFactory,
ImportExportCertificate importExportCertificate,
CreateCertificatesClientServerAuth ccs,
DpsDbContext dpsDbContext)
{
Configuration = config;
_logger = loggerFactory.CreateLogger<DpsEnrollmentGroupProvider>();
_dpsDbContext = dpsDbContext;
_iec = importExportCertificate;
_createCertsService = ccs;
_provisioningServiceClient = ProvisioningServiceClient.CreateFromConnectionString(
Configuration.GetConnectionString("DpsConnection"));
}
public async Task<(string Name, int Id)> CreateDpsEnrollmentGroupAsync(
string enrollmentGroupName, string certificatePublicPemId)
{
_logger.LogInformation("Starting CreateDpsEnrollmentGroupAsync...");
_logger.LogInformation("Creating a new enrollmentGroup...");
var dpsCertificate = _dpsDbContext.DpsCertificates
.FirstOrDefault(t => t.Id == int.Parse(certificatePublicPemId));
var rootCertificate = X509Certificate2.CreateFromPem(
dpsCertificate!.PemPublicKey, dpsCertificate.PemPrivateKey);
// create an intermediate for each group
var certName = $"{enrollmentGroupName}";
var certDpsGroup = _createCertsService.NewIntermediateChainedCertificate(
new DistinguishedName { CommonName = certName, Country = "CH" },
new ValidityPeriod { ValidFrom = DateTime.UtcNow, ValidTo = DateTime.UtcNow.AddYears(50) },
2, certName, rootCertificate);
// get the public key certificate for the enrollment
var pemDpsGroupPublic = _iec.PemExportPublicKeyCertificate(certDpsGroup);
string pemDpsGroupPrivate = string.Empty;
using (ECDsa? ecdsa = certDpsGroup.GetECDsaPrivateKey())
{
pemDpsGroupPrivate = ecdsa!.ExportECPrivateKeyPem();
FileProvider.WriteToDisk($"{enrollmentGroupName}-private.pem", pemDpsGroupPrivate);
}
Attestation attestation = X509Attestation.CreateFromRootCertificates(pemDpsGroupPublic);
EnrollmentGroup enrollmentGroup = CreateEnrollmentGroup(enrollmentGroupName, attestation);
_logger.LogInformation("{enrollmentGroup}", enrollmentGroup);
_logger.LogInformation("Adding new enrollmentGroup...");
EnrollmentGroup enrollmentGroupResult = await _provisioningServiceClient
.CreateOrUpdateEnrollmentGroupAsync(enrollmentGroup);
_logger.LogInformation("EnrollmentGroup created with success.");
_logger.LogInformation("{enrollmentGroupResult}", enrollmentGroupResult);
DpsEnrollmentGroup newItem = await PersistData(enrollmentGroupName,
dpsCertificate, pemDpsGroupPublic, pemDpsGroupPrivate);
return (newItem.Name, newItem.Id);
}
private async Task<DpsEnrollmentGroup> PersistData(string enrollmentGroupName,
DpsCertificate dpsCertificate, string pemDpsGroupPublic, string pemDpsGroupPrivate)
{
var newItem = new DpsEnrollmentGroup
{
DpsCertificateId = dpsCertificate.Id,
Name = enrollmentGroupName,
DpsCertificate = dpsCertificate,
PemPublicKey = pemDpsGroupPublic,
PemPrivateKey = pemDpsGroupPrivate
};
_dpsDbContext.DpsEnrollmentGroups.Add(newItem);
dpsCertificate.DpsEnrollmentGroups.Add(newItem);
await _dpsDbContext.SaveChangesAsync();
return newItem;
}
private static EnrollmentGroup CreateEnrollmentGroup(string enrollmentGroupName, Attestation attestation)
{
return new EnrollmentGroup(enrollmentGroupName, attestation)
{
ProvisioningStatus = ProvisioningStatus.Enabled,
ReprovisionPolicy = new ReprovisionPolicy
{
MigrateDeviceData = false,
UpdateHubAssignment = true
},
Capabilities = new DeviceCapabilities
{
IotEdge = false
},
InitialTwinState = new TwinState(
new TwinCollection("{ \"updatedby\"\"" + "damien" + "\", \"timeZone\"\"" + TimeZoneInfo.Local.DisplayName + "\" }"),
new TwinCollection("{ }")
)
};
}
public async Task<List<DpsEnrollmentGroup>> GetDpsGroupsAsync(int? certificateId = null)
{
if (certificateId == null)
{
return await _dpsDbContext.DpsEnrollmentGroups.ToListAsync();
}
return await _dpsDbContext.DpsEnrollmentGroups
.Where(s => s.DpsCertificateId == certificateId).ToListAsync();
}
public async Task<DpsEnrollmentGroup?> GetDpsGroupAsync(int id)
{
return await _dpsDbContext.DpsEnrollmentGroups
.FirstOrDefaultAsync(d => d.Id == id);
}
}
Register a device in the enrollment group
The DpsRegisterDeviceProvider class creates a new device chained certificate using the enrollment group certificate and creates this using the ProvisioningDeviceClient. The transport ProvisioningTransportHandlerAmqp is set in this example. There are different transport types possible and you need to chose the one which best meets your needs. The device certificate uses an ECDsa private key and stores everything to the database. The PFX for windows is stored directly to the file system. I use pem files and create the certificate from these in the device client sending data to the hub and this is platform independent. The create PFX file requires a password to use it.
public class DpsRegisterDeviceProvider
{
private IConfiguration Configuration { get; set; }
private readonly ILogger<DpsRegisterDeviceProvider> _logger;
private readonly DpsDbContext _dpsDbContext;
private readonly ImportExportCertificate _iec;
private readonly CreateCertificatesClientServerAuth _createCertsService;
public DpsRegisterDeviceProvider(IConfiguration config,
ILoggerFactory loggerFactory,
ImportExportCertificate importExportCertificate,
CreateCertificatesClientServerAuth ccs,
DpsDbContext dpsDbContext)
{
Configuration = config;
_logger = loggerFactory.CreateLogger<DpsRegisterDeviceProvider>();
_dpsDbContext = dpsDbContext;
_iec = importExportCertificate;
_createCertsService = ccs;
}
public async Task<(int? DeviceId, string? ErrorMessage)> RegisterDeviceAsync(
string deviceCommonNameDevice, string dpsEnrollmentGroupId)
{
int? deviceId = null;
var scopeId = Configuration["ScopeId"];
var dpsEnrollmentGroup = _dpsDbContext.DpsEnrollmentGroups
.FirstOrDefault(t => t.Id == int.Parse(dpsEnrollmentGroupId));
var certDpsEnrollmentGroup = X509Certificate2.CreateFromPem(
dpsEnrollmentGroup!.PemPublicKey, dpsEnrollmentGroup.PemPrivateKey);
var newDevice = new DpsEnrollmentDevice
{
Password = GetEncodedRandomString(30),
Name = deviceCommonNameDevice.ToLower(),
DpsEnrollmentGroupId = dpsEnrollmentGroup.Id,
DpsEnrollmentGroup = dpsEnrollmentGroup
};
var certDevice = _createCertsService.NewDeviceChainedCertificate(
new DistinguishedName { CommonName = $"{newDevice.Name}" },
new ValidityPeriod { ValidFrom = DateTime.UtcNow, ValidTo = DateTime.UtcNow.AddYears(50) },
$"{newDevice.Name}", certDpsEnrollmentGroup);
var deviceInPfxBytes = _iec.ExportChainedCertificatePfx(newDevice.Password,
certDevice, certDpsEnrollmentGroup);
// This is required if you want PFX exports to work.
newDevice.PathToPfx = FileProvider.WritePfxToDisk($"{newDevice.Name}.pfx", deviceInPfxBytes);
// get the public key certificate for the device
newDevice.PemPublicKey = _iec.PemExportPublicKeyCertificate(certDevice);
FileProvider.WriteToDisk($"{newDevice.Name}-public.pem", newDevice.PemPublicKey);
using (ECDsa? ecdsa = certDevice.GetECDsaPrivateKey())
{
newDevice.PemPrivateKey = ecdsa!.ExportECPrivateKeyPem();
FileProvider.WriteToDisk($"{newDevice.Name}-private.pem", newDevice.PemPrivateKey);
}
// setup Windows store deviceCert
var pemExportDevice = _iec.PemExportPfxFullCertificate(certDevice, newDevice.Password);
var certDeviceForCreation = _iec.PemImportCertificate(pemExportDevice, newDevice.Password);
using (var security = new SecurityProviderX509Certificate(certDeviceForCreation, new X509Certificate2Collection(certDpsEnrollmentGroup)))
// To optimize for size, reference only the protocols used by your application.
using (var transport = new ProvisioningTransportHandlerAmqp(TransportFallbackType.TcpOnly))
//using (var transport = new ProvisioningTransportHandlerHttp())
//using (var transport = new ProvisioningTransportHandlerMqtt(TransportFallbackType.TcpOnly))
//using (var transport = new ProvisioningTransportHandlerMqtt(TransportFallbackType.WebSocketOnly))
{
var client = ProvisioningDeviceClient.Create("global.azure-devices-provisioning.net",
scopeId, security, transport);
try
{
var result = await client.RegisterAsync();
_logger.LogInformation("DPS client created {result}", result);
}
catch (Exception ex)
{
_logger.LogError("DPS client created {result}", ex.Message);
return (null, ex.Message);
}
}
_dpsDbContext.DpsEnrollmentDevices.Add(newDevice);
dpsEnrollmentGroup.DpsEnrollmentDevices.Add(newDevice);
await _dpsDbContext.SaveChangesAsync();
deviceId = newDevice.Id;
return (deviceId, null);
}
private static string GetEncodedRandomString(int length)
{
var base64 = Convert.ToBase64String(GenerateRandomBytes(length));
return base64;
}
private static byte[] GenerateRandomBytes(int length)
{
var byteArray = new byte[length];
RandomNumberGenerator.Fill(byteArray);
return byteArray;
}
public async Task<List<DpsEnrollmentDevice>> GetDpsDevicesAsync(int? dpsEnrollmentGroupId)
{
if(dpsEnrollmentGroupId == null)
{
return await _dpsDbContext.DpsEnrollmentDevices.ToListAsync();
}
return await _dpsDbContext.DpsEnrollmentDevices.Where(s => s.DpsEnrollmentGroupId == dpsEnrollmentGroupId).ToListAsync();
}
public async Task<DpsEnrollmentDevice?> GetDpsDeviceAsync(int id)
{
return await _dpsDbContext.DpsEnrollmentDevices
.Include(device => device.DpsEnrollmentGroup)
.FirstOrDefaultAsync(d => d.Id == id);
}
}
Download certificates and use
The private and the public pem files are used to setup the Azure IoT Hub device and send data from the device to the server. A HTML form is used to download the files. The form sends a post request to the file download API.
<form action="/api/FileDownload/DpsDevicePublicKeyPem" method="post">
<input type="hidden" value="@Model.DpsDevice.Id" id="Id" name="Id" />
<button type="submit" style="padding-left0" class="btn btn-link">Download Public PEM</button>
</form>
The DpsDevicePublicKeyPemAsync method implements the file download. The method gets the data from the database and returns this as pem file.
[HttpPost("DpsDevicePublicKeyPem")]
public async Task<IActionResult> DpsDevicePublicKeyPemAsync([FromForm] int id)
{
var cert = await _dpsRegisterDeviceProvider
.GetDpsDeviceAsync(id);
if (cert == null) throw new ArgumentNullException(nameof(cert));
if (cert.PemPublicKey == null)
throw new ArgumentNullException(nameof(cert.PemPublicKey));
return File(Encoding.UTF8.GetBytes(cert.PemPublicKey),
"application/octet-stream",
$"{cert.Name}-public.pem");
}
The device UI displays the data and allows the authenticated user to download the files.
The CertificateManager and the Microsoft.Azure.Devices.Client Nuget packages are used to implement the IoT Hub device client. The pem files with the public certificate and the private key can be loaded into a X509Certificate instance. This is then used to send the data using the DeviceAuthenticationWithX509Certificate class. The SendEvent method sends the data using the IoT Hub device Message class.
var serviceProvider = new ServiceCollection()
.AddCertificateManager()
.BuildServiceProvider();
var iec = serviceProvider.GetService<ImportExportCertificate>();
#region pem
var deviceNamePem = "robot1-feed";
var certPem = File.ReadAllText($"{_pathToCerts}{deviceNamePem}-public.pem");
var eccPem = File.ReadAllText($"{_pathToCerts}{deviceNamePem}-private.pem");
var cert = X509Certificate2.CreateFromPem(certPem, eccPem);
// setup deviceCert windows store export
var pemDeviceCertPrivate = iec!.PemExportPfxFullCertificate(cert);
var certDevice = iec.PemImportCertificate(pemDeviceCertPrivate);
#endregion pem
var auth = new DeviceAuthenticationWithX509Certificate(deviceNamePem, certDevice);
var deviceClient = DeviceClient.Create(iotHubUrl, auth, transportType);
if (deviceClient == null)
{
Console.WriteLine("Failed to create DeviceClient!");
}
else
{
Console.WriteLine("Successfully created DeviceClient!");
SendEvent(deviceClient).Wait();
}
Notes
Using certificates in .NET and windows is complicated due to how the private keys are handled and loaded. The private keys need to be exported or imported into the stores etc. This is not an easy API to get working and the docs for this are confusing.
This type of device transport and the default setup for the device would need to be adapted for your system. In this example, I used ECDsa certificates but you could also use RSA based keys. The root certificate could be replaced with a CA issued one. I created long living certificates because I do not want the devices to stop working in the field. This should be moved to a configuration. A certificate rotation flow would make sense as well.
In the follow up articles, I plan to save the events in hot and cold path events and implement device enable, disable flows. I also plan to write about the device twins. The device twins is a excellent way of sharing data in both directions.
Links
https//github.com/Azure/azure-iot-sdk-csharp
https//github.com/damienbod/AspNetCoreCertificates
Creating Certificates for X.509 security in Azure IoT Hub using .NET Core
https//learn.microsoft.com/en-us/azure/iot-hub/troubleshoot-error-codes
https//stackoverflow.com/questions/52750160/what-is-the-rationale-for-all-the-different-x509keystorageflags/52840537#52840537
https//github.com/dotnet/runtime/issues/19581
https//www.nuget.org/packages/CertificateManager
Azure IoT Hub Documentation | Microsoft Learn