event-grid-iot-edge/Common/auth/SecurityDaemonClient.cs

288 строки
15 KiB
C#

// -----------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
// -----------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Security;
namespace Microsoft.Azure.EventGridEdge.IotEdge
{
[Obsolete("This is a pubternal API that's being made public as a stop-gap measure. It will be removed from the Event Grid SDK nuget package as soon IoT Edge SDK ships with a built-in a security daemon client.")]
public sealed class SecurityDaemonClient : IDisposable
{
private const string UnixScheme = "unix";
private const int DefaultServerCertificateValidityInDays = 90;
private const int DefaultIdentityCertificateValidityInDays = 7;
private readonly JsonSerializerSettings jsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.None,
NullValueHandling = NullValueHandling.Ignore,
FloatParseHandling = FloatParseHandling.Decimal,
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = new JsonConverter[] { new StringEnumConverter() },
};
private readonly string moduleGenerationId;
private readonly string edgeGatewayHostName;
private readonly string workloadApiVersion;
private readonly HttpClient httpClient;
private readonly Uri getTrustBundleUri;
private readonly Uri postIdentityCertificateRequestUri;
private readonly Uri postServerCertificateRequestUri;
private readonly string asString;
public SecurityDaemonClient()
{
this.ModuleId = Environment.GetEnvironmentVariable("IOTEDGE_MODULEID");
this.DeviceId = Environment.GetEnvironmentVariable("IOTEDGE_DEVICEID");
string iotHubHostName = Environment.GetEnvironmentVariable("IOTEDGE_IOTHUBHOSTNAME");
this.IotHubName = iotHubHostName.Split('.').FirstOrDefault();
this.moduleGenerationId = Environment.GetEnvironmentVariable("IOTEDGE_MODULEGENERATIONID");
this.edgeGatewayHostName = Environment.GetEnvironmentVariable("IOTEDGE_GATEWAYHOSTNAME");
this.workloadApiVersion = Environment.GetEnvironmentVariable("IOTEDGE_APIVERSION");
string workloadUriString = Environment.GetEnvironmentVariable("IOTEDGE_WORKLOADURI");
Validate.ArgumentNotNullOrEmpty(this.ModuleId, nameof(this.ModuleId));
Validate.ArgumentNotNullOrEmpty(this.DeviceId, nameof(this.DeviceId));
Validate.ArgumentNotNullOrEmpty(this.IotHubName, nameof(this.IotHubName));
Validate.ArgumentNotNullOrEmpty(this.moduleGenerationId, nameof(this.moduleGenerationId));
Validate.ArgumentNotNullOrEmpty(this.edgeGatewayHostName, nameof(this.edgeGatewayHostName));
Validate.ArgumentNotNullOrEmpty(this.workloadApiVersion, nameof(this.workloadApiVersion));
Validate.ArgumentNotNullOrEmpty(workloadUriString, nameof(workloadUriString));
var workloadUri = new Uri(workloadUriString);
string baseUrlForRequests;
if (workloadUri.Scheme.Equals(SecurityDaemonClient.UnixScheme, StringComparison.OrdinalIgnoreCase))
{
baseUrlForRequests = $"http://{workloadUri.Segments.Last()}";
this.httpClient = new HttpClient(new HttpUdsMessageHandler(workloadUri));
}
else if (workloadUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
workloadUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
baseUrlForRequests = workloadUriString;
this.httpClient = new HttpClient();
}
else
{
throw new InvalidOperationException($"Unknown workloadUri scheme specified. {workloadUri}");
}
baseUrlForRequests = baseUrlForRequests.TrimEnd();
this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
string encodedApiVersion = UrlEncoder.Default.Encode(this.workloadApiVersion);
string encodedModuleId = UrlEncoder.Default.Encode(this.ModuleId);
string encodedModuleGenerationId = UrlEncoder.Default.Encode(this.moduleGenerationId);
this.getTrustBundleUri = new Uri($"{baseUrlForRequests}/trust-bundle?api-version={encodedApiVersion}");
this.postIdentityCertificateRequestUri = new Uri($"{baseUrlForRequests}/modules/{encodedModuleId}/certificate/identity?api-version={encodedApiVersion}");
this.postServerCertificateRequestUri = new Uri($"{baseUrlForRequests}/modules/{encodedModuleId}/genid/{encodedModuleGenerationId}/certificate/server?api-version={encodedApiVersion}");
var settings = new
{
this.ModuleId,
this.DeviceId,
IotHubHostName = iotHubHostName,
ModuleGenerationId = this.moduleGenerationId,
GatewayHostName = this.edgeGatewayHostName,
WorkloadUri = workloadUriString,
WorkloadApiVersion = this.workloadApiVersion,
};
this.asString = $"{nameof(SecurityDaemonClient)}{JsonConvert.SerializeObject(settings, Formatting.None, this.jsonSettings)}";
}
public string IotHubName { get; }
public string DeviceId { get; }
public string ModuleId { get; }
public void Dispose() => this.httpClient.Dispose();
public override string ToString() => this.asString;
public Task<(X509Certificate2 serverCert, X509Certificate2[] certChain)> GetServerCertificateAsync(CancellationToken token = default)
{
return this.GetServerCertificateAsync(TimeSpan.FromDays(SecurityDaemonClient.DefaultServerCertificateValidityInDays), token);
}
public async Task<(X509Certificate2 serverCert, X509Certificate2[] certChain)> GetServerCertificateAsync(TimeSpan validity, CancellationToken token = default)
{
var request = new ServerCertificateRequest
{
CommonName = this.edgeGatewayHostName,
Expiration = DateTime.UtcNow.Add(validity),
};
string requestString = JsonConvert.SerializeObject(request, Formatting.None, this.jsonSettings);
using (var content = new StringContent(requestString, Encoding.UTF8, "application/json"))
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, this.postServerCertificateRequestUri) { Content = content })
using (HttpResponseMessage httpResponse = await this.httpClient.SendAsync(httpRequest, token))
{
string responsePayload = await httpResponse.Content.ReadAsStringAsync();
if (httpResponse.StatusCode == HttpStatusCode.Created)
{
CertificateResponse cr = JsonConvert.DeserializeObject<CertificateResponse>(responsePayload, this.jsonSettings);
return this.CreateX509Certificates(cr);
}
throw new InvalidOperationException($"Failed to retrieve server certificate from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' ResponsePayload='{responsePayload}' Request={requestString} This={this}");
}
}
public Task<(X509Certificate2 identityCert, X509Certificate2[] certChain)> GetIdentityCertificateAsync(CancellationToken token = default)
{
return this.GetIdentityCertificateAsync(TimeSpan.FromDays(SecurityDaemonClient.DefaultIdentityCertificateValidityInDays), token);
}
public async Task<(X509Certificate2 identityCert, X509Certificate2[] certChain)> GetIdentityCertificateAsync(TimeSpan validity, CancellationToken token = default)
{
var request = new IdentityCertificateRequest
{
Expiration = DateTime.UtcNow.Add(validity),
};
string requestString = JsonConvert.SerializeObject(request, Formatting.None, this.jsonSettings);
using (var content = new StringContent(requestString, Encoding.UTF8, "application/json"))
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, this.postIdentityCertificateRequestUri) { Content = content })
using (HttpResponseMessage httpResponse = await this.httpClient.SendAsync(httpRequest, token))
{
string responsePayload = await httpResponse.Content.ReadAsStringAsync();
if (httpResponse.StatusCode == HttpStatusCode.Created)
{
CertificateResponse cr = JsonConvert.DeserializeObject<CertificateResponse>(responsePayload, this.jsonSettings);
return this.CreateX509Certificates(cr);
}
throw new InvalidOperationException($"Failed to retrieve identity certificate from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' ResponsePayload='{responsePayload}' Request={requestString} This={this}");
}
}
public async Task<X509Certificate2[]> GetTrustBundleAsync(CancellationToken token = default)
{
using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, this.getTrustBundleUri))
using (HttpResponseMessage httpResponse = await this.httpClient.SendAsync(httpRequest, token))
{
string responsePayload = await httpResponse.Content.ReadAsStringAsync();
if (httpResponse.StatusCode == HttpStatusCode.OK)
{
TrustBundleResponse trustBundleResponse = JsonConvert.DeserializeObject<TrustBundleResponse>(responsePayload, this.jsonSettings);
Validate.ArgumentNotNullOrEmpty(trustBundleResponse.Certificate, nameof(trustBundleResponse.Certificate));
string[] rawCerts = ParseCertificateResponse(trustBundleResponse.Certificate);
if (rawCerts.FirstOrDefault() == null)
{
throw new InvalidOperationException($"Failed to retrieve the certificate trust bundle from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' Reason='Security daemon returned an empty response' This={this}");
}
return ConvertToX509(rawCerts);
}
throw new InvalidOperationException($"Failed to retrieve the certificate trust bundle from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' ResponsePayload='{responsePayload}' This={this}");
}
}
private static X509Certificate2[] ConvertToX509(IEnumerable<string> rawCerts) => rawCerts.Select(c => new X509Certificate2(Encoding.UTF8.GetBytes(c))).ToArray();
private static string[] ParseCertificateResponse(string certificateChain, [CallerMemberName] string callerMemberName = default)
{
if (string.IsNullOrEmpty(certificateChain))
{
throw new InvalidOperationException($"Trusted certificates can not be null or empty for {callerMemberName}.");
}
// Extract each certificate's string. The final string from the split will either be empty
// or a non-certificate entry, so it is dropped.
string delimiter = "-----END CERTIFICATE-----";
string[] rawCerts = certificateChain.Split(new[] { delimiter }, StringSplitOptions.None);
return rawCerts.Take(count: rawCerts.Length - 1).Select(c => $"{c}{delimiter}").ToArray();
}
private (X509Certificate2 primaryCert, X509Certificate2[] certChain) CreateX509Certificates(CertificateResponse cr, [CallerMemberName] string callerMemberName = default)
{
Validate.ArgumentNotNullOrEmpty(cr.Certificate, nameof(cr.Certificate));
Validate.ArgumentNotNull(cr.Expiration, nameof(cr.Expiration));
Validate.ArgumentNotNull(cr.PrivateKey, nameof(cr.PrivateKey));
Validate.ArgumentNotNull(cr.PrivateKey.Type, nameof(cr.PrivateKey.Type));
Validate.ArgumentNotNull(cr.PrivateKey.Bytes, nameof(cr.PrivateKey.Bytes));
string[] rawCerts = ParseCertificateResponse(cr.Certificate);
if (rawCerts.Length == 0 ||
string.IsNullOrWhiteSpace(rawCerts[0]))
{
throw new InvalidOperationException($"Failed to retrieve certificate from IoTEdge Security daemon for {callerMemberName}. Reason: Security daemon returned an empty response.");
}
string primaryCert = rawCerts[0];
X509Certificate2[] certChain = ConvertToX509(rawCerts.Skip(1));
RsaPrivateCrtKeyParameters keyParams = null;
var chainCertEntries = new List<X509CertificateEntry>();
Pkcs12Store store = new Pkcs12StoreBuilder().Build();
// note: the seperator between the certificate and private key is added for safety to delineate the cert and key boundary
using (var sr = new StringReader(primaryCert + "\r\n" + cr.PrivateKey.Bytes))
{
var pemReader = new PemReader(sr);
object certObject;
while ((certObject = pemReader.ReadObject()) != null)
{
if (certObject is Org.BouncyCastle.X509.X509Certificate x509Cert)
{
chainCertEntries.Add(new X509CertificateEntry(x509Cert));
}
// when processing certificates generated via openssl certObject type is of AsymmetricCipherKeyPair
if (certObject is AsymmetricCipherKeyPair ackp)
{
certObject = ackp.Private;
}
if (certObject is RsaPrivateCrtKeyParameters rpckp)
{
keyParams = rpckp;
}
}
}
if (keyParams == null)
{
throw new InvalidOperationException($"Private key was not found for {callerMemberName}");
}
store.SetKeyEntry(this.ModuleId, new AsymmetricKeyEntry(keyParams), chainCertEntries.ToArray());
using (var ms = new MemoryStream())
{
store.Save(ms, Array.Empty<char>(), new SecureRandom());
var x509PrimaryCert = new X509Certificate2(ms.ToArray());
return (x509PrimaryCert, certChain);
}
}
}
}