Feature/876 certificate rotation cli (#909)

* Provide certificate update mechanism

* Implement certificate revocation command

* Make RotateCertificateOptions immutable

* Revert obsolete code

* Update Tools/Cli-LoRa-Device-Provisioning/CommandLineOptions/RevokeOptions.cs

Co-authored-by: Daniele Antonio Maggio <damaggio@microsoft.com>

* Fix bug in region validation

* Fix no-cups path

* Log if thumbprint not found

Co-authored-by: Daniele Antonio Maggio <damaggio@microsoft.com>
This commit is contained in:
Bastian Burger 2021-11-29 15:28:56 +01:00 коммит произвёл GitHub
Родитель 29e20febee
Коммит 3a02b39f56
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
5 изменённых файлов: 211 добавлений и 47 удалений

Просмотреть файл

@ -35,7 +35,7 @@
<PackageReference Include="Azure.Storage.Blobs" Version="12.10.0" />
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Crc32.NET" Version="1.2.0" />
<PackageReference Include="Microsoft.Azure.Devices" Version="1.22.0" />
<PackageReference Include="Microsoft.Azure.Devices" Version="1.36.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
</ItemGroup>

Просмотреть файл

@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace LoRaWan.Tools.CLI.CommandLineOptions
{
using CommandLine;
[Verb("revoke", HelpText = "Revokes a client certificate thumbprint for a station.")]
public class RevokeOptions
{
public RevokeOptions(string stationEui, string clientCertificateThumbprint)
{
StationEui = stationEui;
ClientCertificateThumbprint = clientCertificateThumbprint;
}
[Option("stationeui",
Required = true,
HelpText = "Station EUI: Required id '--concentrator' switch is set. A 16 bit hex string ('AABBCCDDEEFFGGHH').")]
public string StationEui { get; }
[Option("client-certificate-thumbprint",
Required = true,
HelpText = "Client certificate thumbprint: A client certificate thumbprint that should be revoked and not accepted anymore by the CUPS/LNS endpoints.")]
public string ClientCertificateThumbprint { get; }
}
}

Просмотреть файл

@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace LoRaWan.Tools.CLI.CommandLineOptions
{
using CommandLine;
[Verb("rotate-certificate", HelpText = "Rotates the certificates for a station.")]
public class RotateCertificateOptions
{
public RotateCertificateOptions(string stationEui, string certificateBundleLocation, string clientCertificateThumbprint)
{
StationEui = stationEui;
CertificateBundleLocation = certificateBundleLocation;
ClientCertificateThumbprint = clientCertificateThumbprint;
}
[Option("stationeui",
Required = true,
HelpText = "Station EUI: Required id '--concentrator' switch is set. A 16 bit hex string ('AABBCCDDEEFFGGHH').")]
public string StationEui { get; }
[Option("certificate-bundle-location",
Required = true,
HelpText = "Certificate bundle location: Points to the location of the (UTF-8-encoded) certificate bundle file.")]
public string CertificateBundleLocation { get; }
[Option("client-certificate-thumbprint",
Required = true,
HelpText = "Client certificate thumbprint: A client certificate thumbprint that should be accepted by the CUPS/LNS endpoints.")]
public string ClientCertificateThumbprint { get; }
}
}

Просмотреть файл

@ -7,9 +7,7 @@ namespace LoRaWan.Tools.CLI.Helpers
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Azure.Storage.Blobs;
using LoRaWan.Tools.CLI.Options;
using Microsoft.Azure.Devices;
using Microsoft.Azure.Devices.Shared;
@ -909,8 +907,12 @@ namespace LoRaWan.Tools.CLI.Helpers
var isValid = true;
TrackErrorIf(string.IsNullOrEmpty(opts.StationEui), "'Concentrator' device type has been specified but StationEui option is missing.");
TrackErrorIf(string.IsNullOrEmpty(opts.Region), "'Concentrator' device type has been specified but Region option is missing.");
TrackErrorIf(opts.Region is { } region && !File.Exists(Path.Combine(DefaultRouterConfigFolder, $"{region.ToUpperInvariant()}.json")),
$"'Concentrator' device type has been specified with Region '{opts.Region.ToUpperInvariant()}' but no default router config file was found.");
if (opts.Region is { } region)
{
TrackErrorIf(!File.Exists(Path.Combine(DefaultRouterConfigFolder, $"{region.ToUpperInvariant()}.json")),
$"'Concentrator' device type has been specified with Region '{region.ToUpperInvariant()}' but no default router config file was found.");
}
if (opts.NoCups)
{
TrackErrorIf(opts.TcUri is not null, "TC URI must not be defined if --no-cups is set.");
@ -952,19 +954,22 @@ namespace LoRaWan.Tools.CLI.Helpers
var propObject = JsonConvert.DeserializeObject<JObject>(jsonString);
twinProperties.Desired[TwinProperty.RouterConfig] = propObject;
// Add CUPS configuration
twinProperties.Desired[TwinProperty.Cups] = new JObject
if (!opts.NoCups)
{
[TwinProperty.TcCredentialUrl] = certificateBundleLocation,
[TwinProperty.TcCredentialCrc] = crcChecksum,
[TwinProperty.CupsCredentialUrl] = certificateBundleLocation,
[TwinProperty.CupsCredentialCrc] = crcChecksum,
[TwinProperty.CupsUri] = opts.CupsUri,
[TwinProperty.TcUri] = opts.TcUri,
};
// Add CUPS configuration
twinProperties.Desired[TwinProperty.Cups] = new JObject
{
[TwinProperty.TcCredentialUrl] = certificateBundleLocation,
[TwinProperty.TcCredentialCrc] = crcChecksum,
[TwinProperty.CupsCredentialUrl] = certificateBundleLocation,
[TwinProperty.CupsCredentialCrc] = crcChecksum,
[TwinProperty.CupsUri] = opts.CupsUri,
[TwinProperty.TcUri] = opts.TcUri,
};
// Add client certificate thumbprints
twinProperties.Desired[TwinProperty.ClientThumbprint] = new JArray(opts.ClientCertificateThumbprints);
// Add client certificate thumbprints
twinProperties.Desired[TwinProperty.ClientThumbprint] = new JArray(opts.ClientCertificateThumbprints);
}
return new Twin
{

Просмотреть файл

@ -6,15 +6,18 @@ namespace LoRaWan.Tools.CLI
using System;
using System.Buffers.Binary;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs.Models;
using CommandLine;
using LoRaWan.Tools.CLI.CommandLineOptions;
using LoRaWan.Tools.CLI.Helpers;
using LoRaWan.Tools.CLI.Options;
using Microsoft.Azure.Devices.Shared;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
public class Program
{
@ -31,7 +34,7 @@ namespace LoRaWan.Tools.CLI
Console.ResetColor();
Console.WriteLine();
var success = await Parser.Default.ParseArguments<ListOptions, QueryOptions, VerifyOptions, BulkVerifyOptions, AddOptions, UpdateOptions, RemoveOptions>(args)
var success = await Parser.Default.ParseArguments<ListOptions, QueryOptions, VerifyOptions, BulkVerifyOptions, AddOptions, UpdateOptions, RemoveOptions, RotateCertificateOptions, RevokeOptions>(args)
.MapResult(
(ListOptions opts) => RunListAndReturnExitCode(opts),
(QueryOptions opts) => RunQueryAndReturnExitCode(opts),
@ -40,6 +43,8 @@ namespace LoRaWan.Tools.CLI
(AddOptions opts) => RunAddAndReturnExitCode(opts),
(UpdateOptions opts) => RunUpdateAndReturnExitCode(opts),
(RemoveOptions opts) => RunRemoveAndReturnExitCode(opts),
(RotateCertificateOptions opts) => RunRotateCertificateAndReturnExitCodeAsync(opts),
(RevokeOptions opts) => RunRevokeAndReturnExitCodeAsync(opts),
errs => Task.FromResult(false));
if (success)
@ -163,43 +168,25 @@ namespace LoRaWan.Tools.CLI
return false;
}
// renormalize to Unix line endings
var certificateBundleBlobName = opts.StationEui;
var blobClient = configurationHelper.CertificateStorageContainerClient.GetBlobClient(certificateBundleBlobName);
var certificateContent = Regex.Replace(File.ReadAllText(opts.CertificateBundleLocation), @"\r\n|\n\r|\n|\r", "\n");
try
if (await iotDeviceHelper.QueryDeviceTwin(opts.StationEui, configurationHelper) is not null)
{
_ = await blobClient.UploadAsync(new BinaryData(Encoding.UTF8.GetBytes(certificateContent)), overwrite: false);
}
catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobAlreadyExists)
{
StatusConsole.WriteLogLine(MessageType.Error, $"Uploading certificate bundle failed because bundle already exists. Please use the 'update' verb to update existing concentrator configuration.");
return false;
}
catch (RequestFailedException ex)
{
StatusConsole.WriteLogLine(MessageType.Error, $"Uploading certificate bundle failed with error: '{ex.Message}'.");
StatusConsole.WriteLogLine(MessageType.Error, "Station was already created, please use the 'update' verb to update an existing station.");
return false;
}
try
if (opts.NoCups)
{
var crc = new Force.Crc32.Crc32Algorithm();
var crcHash = BinaryPrimitives.ReadUInt32BigEndian(crc.ComputeHash(Encoding.UTF8.GetBytes(certificateContent)));
var twin = iotDeviceHelper.CreateConcentratorTwin(opts, crcHash, blobClient.Uri);
var success = await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, true);
if (!success) await CleanupAsync();
return success;
var twin = iotDeviceHelper.CreateConcentratorTwin(opts, 0, null);
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, true);
}
catch (Exception)
else
{
// If the twin was not successfully created, remove the uploaded certificate bundle.
await CleanupAsync();
throw;
return await UploadCertificateBundleAsync(opts.CertificateBundleLocation, opts.StationEui, async (crcHash, bundleStorageUri) =>
{
var twin = iotDeviceHelper.CreateConcentratorTwin(opts, crcHash, bundleStorageUri);
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, true);
});
}
Task CleanupAsync() => blobClient.DeleteIfExistsAsync();
}
opts = iotDeviceHelper.CompleteMissingAddOptions(opts, configurationHelper);
@ -223,6 +210,84 @@ namespace LoRaWan.Tools.CLI
return isSuccess;
}
private static async Task<bool> RunRotateCertificateAndReturnExitCodeAsync(RotateCertificateOptions opts)
{
if (!configurationHelper.ReadConfig())
return false;
if (!File.Exists(opts.CertificateBundleLocation))
{
StatusConsole.WriteLogLine(MessageType.Error, "Certificate bundle does not exist at defined location.");
return false;
}
var twin = await iotDeviceHelper.QueryDeviceTwin(opts.StationEui, configurationHelper);
if (twin is null)
{
StatusConsole.WriteLogLine(MessageType.Error, "Device was not found in IoT Hub. Please create it first.");
return false;
}
var twinJObject = JsonConvert.DeserializeObject<JObject>(twin.Properties.Desired.ToJson());
var cupsProperties = twinJObject[TwinProperty.Cups];
var oldCupsCredentialBundleLocation = new Uri(cupsProperties[TwinProperty.CupsCredentialUrl].ToString());
var oldTcCredentialBundleLocation = new Uri(cupsProperties[TwinProperty.TcCredentialUrl].ToString());
// Upload new certificate bundle
var success = await UploadCertificateBundleAsync(opts.CertificateBundleLocation, opts.StationEui, async (crcHash, bundleStorageUri) =>
{
var thumbprints = (JArray)twinJObject[TwinProperty.ClientThumbprint];
if (!thumbprints.Any(t => string.Equals(t.ToString(), opts.ClientCertificateThumbprint, StringComparison.OrdinalIgnoreCase)))
thumbprints.Add(JToken.Parse($"\"{opts.ClientCertificateThumbprint}\""));
twin.Properties.Desired[TwinProperty.ClientThumbprint] = thumbprints;
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.CupsCredentialCrc] = crcHash;
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.TcCredentialCrc] = crcHash;
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.CupsCredentialUrl] = bundleStorageUri;
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.TcCredentialUrl] = bundleStorageUri;
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, isNewDevice: false);
});
// Clean up old certificate bundles
try
{
_ = await configurationHelper.CertificateStorageContainerClient.DeleteBlobIfExistsAsync(oldCupsCredentialBundleLocation.Segments.Last());
}
finally
{
_ = await configurationHelper.CertificateStorageContainerClient.DeleteBlobIfExistsAsync(oldTcCredentialBundleLocation.Segments.Last());
}
return success;
}
private static async Task<bool> RunRevokeAndReturnExitCodeAsync(RevokeOptions opts)
{
if (!configurationHelper.ReadConfig())
return false;
var twin = await iotDeviceHelper.QueryDeviceTwin(opts.StationEui, configurationHelper);
if (twin is null)
{
StatusConsole.WriteLogLine(MessageType.Error, "Device was not found in IoT Hub. Please create it first.");
return false;
}
var twinJObject = JsonConvert.DeserializeObject<JObject>(twin.Properties.Desired.ToJson());
var clientThumprints = twinJObject[TwinProperty.ClientThumbprint];
var t = clientThumprints.FirstOrDefault(t => t.ToString().Equals(opts.ClientCertificateThumbprint, StringComparison.OrdinalIgnoreCase));
if (t is null)
StatusConsole.WriteLogLine(MessageType.Error, "Specified thumbprint not found.");
t?.Remove();
twin.Properties.Desired[TwinProperty.ClientThumbprint] = clientThumprints;
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, isNewDevice: false);
}
private static async Task<bool> RunUpdateAndReturnExitCode(UpdateOptions opts)
{
if (!configurationHelper.ReadConfig())
@ -270,6 +335,40 @@ namespace LoRaWan.Tools.CLI
return isSuccess;
}
private static async Task<bool> UploadCertificateBundleAsync(string certificateBundleLocation, string stationEui, Func<uint, Uri, Task<bool>> uploadSuccessActionAsync)
{
var certificateBundleBlobName = $"{stationEui}-{Guid.NewGuid():N}";
var blobClient = configurationHelper.CertificateStorageContainerClient.GetBlobClient(certificateBundleBlobName);
var fileContent = await File.ReadAllTextAsync(certificateBundleLocation);
var certificateContent = Regex.Replace(fileContent, @"\r\n|\n\r|\n|\r", "\n");
try
{
_ = await blobClient.UploadAsync(new BinaryData(Encoding.UTF8.GetBytes(certificateContent)), overwrite: false);
}
catch (RequestFailedException ex)
{
StatusConsole.WriteLogLine(MessageType.Error, $"Uploading certificate bundle failed with error: '{ex.Message}'.");
return false;
}
try
{
var crc = new Force.Crc32.Crc32Algorithm();
var crcHash = BinaryPrimitives.ReadUInt32BigEndian(crc.ComputeHash(Encoding.UTF8.GetBytes(certificateContent)));
if (!await uploadSuccessActionAsync(crcHash, blobClient.Uri))
await CleanupAsync();
return true;
}
catch (Exception)
{
await CleanupAsync();
throw;
}
Task CleanupAsync() => blobClient.DeleteIfExistsAsync();
}
private static async Task<bool> RunRemoveAndReturnExitCode(RemoveOptions opts)
{
if (!configurationHelper.ReadConfig())