ASP.NET Core Encryption/Decryption
HttpMessageHandler ResourceFilter
▌Introduction
Though the request is often
protected with HTTPS, sometimes we would like to encrypt the content (Http
body) of a request in some scenarios.
For example, we have 3
microservices:
A (The data owner = Sender) -> B
(Forward data) -> C (Receiver)
We don’t want B to
see/know the data in this forwarding process, so that we must
encrypt it on A (Sender) by C’s public key and decrypt on C (Receiver) with
private key.
Shorten the story and this
tutorial’s sample code as below picture,
We will create custom HttpMessageHandler to
encrypt the content and custom ResourceFilter to
decrypt it on the fly in this article.
Furthermore, the Sender may get the
old or incorrect Public Key of Receiver to encrypt the content and ends in the
Receiver cannot decrypt the encrypted content with its Private Key.
We will also use Polly’s retry
policy to get the right Public Key from Receiver and then retry the request
again to make sure the whole delivery is secure and stable. The process is as
following,
· CustomHeaderHttpClientHandler: Put
some custom headers on the request.
· EncryptHttpClientHandler:
Encrypt the content of the request.
For retrying request, we will use
Polly to create a Retry Policy:
· PollyRetryPolicyHandler: Get
the right Public Key from previous response -> encrypt -> send request.
· DecryptRequestFilter:
Decrypt the content of a request with the Private Key, return Http status code
422(Unprocessable Entity) if fails to decrypt.
The sample code is on my Github, notice
that I implemented the Sender and Receiver in the sample Web API project.
(In real world, of course they are
separated from each other!)
▌Environment
▋ASP.NET Core 3.1.102
▌Implement library
▋RSA
▋RsaService.cs
public class RsaService : IAsymKeyService, IDisposable
{
private readonly int keySize;
private readonly int maxDataSize;
private readonly HashAlgorithmName hashAlgorithm;
private readonly RSASignaturePadding signaturePadding;
private readonly RSAEncryptionPadding encryptionPadding;
private readonly Encoding encoding = Encoding.UTF8;
/// <summary>
/// Constructor using default RSA2 options
/// </summary>
public RsaService()
{
this.keySize = 2048;
this.maxDataSize = this.keySize / 8; // RSA is only able to encrypt data with max size = key size
this.hashAlgorithm = HashAlgorithmName.SHA256;
this.signaturePadding = RSASignaturePadding.Pss;
this.encryptionPadding = RSAEncryptionPadding.OaepSHA256;
this.encoding = Encoding.UTF8;
}
/// <summary>
/// Create RSA key
/// </summary>
/// <param name="meta">Key's metadata</param>
/// <returns>Cipherkey object</returns>
public async Task<CipherKey> CreateKeyAsync()
{
var keys = RsaKeyGenerator.Pkcs8Key(2048, false);
var privateKey = keys[0];
var publicKey = keys[1];
var key = new CipherKey()
{
Id = Guid.NewGuid().ToString(),
KeyType = KeyTypeEnum.RSA,
PublicKey = publicKey,
PrivateKey = privateKey
};
return await Task.FromResult(key);
}
/// <summary>
/// Encrypt
/// </summary>
/// <param name="key">CipherKey object</param>
/// <param name="data">Input data</param>
/// <returns>Encrypted data</returns>
public async Task<string> EncryptAsync(string publicKey, string data)
{
using (var rsaUtil = new RsaPkcs8Util(this.encoding, publicKey))
{
var cipher = rsaUtil.EncryptByDataSize(this.maxDataSize, data, this.encryptionPadding);
return await Task.FromResult(cipher);
}
}
/// <summary>
/// Decrypt
/// </summary>
/// <param name="key">CipherKey object</param>
/// <param name="cipherData">Encrypted data</param>
/// <returns>Decrypted data</returns>
public async Task<string> DecryptAsync(string privateKey, string cipherData)
{
using (var rsaUtil = new RsaPkcs8Util(this.encoding, string.Empty, privateKey))
{
var text = rsaUtil.DecryptByDataSize(this.maxDataSize, cipherData, this.encryptionPadding);
return await Task.FromResult(text);
}
}
public void Dispose()
{
}
}
▋Key Manager
We will store the keys in
MemoryCache, so I create a Key Manager that can save or get the key
conveniently.
▌Implement Sender
▋HttpMessageHandler
Since we will design the retry
process if the Receiver cannot decrypt the encrypted content of request from
Sender. The Sender must keep the original content before encrypted in
MemoryCache for encrypting again with the correct Public Key in the retry
process.
How does Sender know which content
it shall get back from MemoryCache?
Lets put the content’s MemoryCache
key into a custom Http header: “Request-Cache-Id” on the request, and
when Receiver response 422 (that means Receiver fails to decrypt the content), Receiver
put the “Request-Cache-Id” and “Public-Key” into the response’s
header to make sure Sender can get the mapping content from MemoryCache by “Request-Cache-Id”
and then encrypt it again with the correct “Public-Key” in the retry
process.
Also we would like to know how many
times the request is retried, so we put the other custom header: “Retry-Times”
to record retry times.
So first lets create a
HttpMessageHandler to append the 2 custom headers on the request.
▋CustomHttpHeaderFactory.cs
public class CustomHttpHeaderFactory
{
/// <summary>
/// Http header name for keeping value of retried times
/// </summary>
/// <remarks>
/// This header is used to check how many times the request had been retied.
/// </remarks>
public static string RetryTimes { get; } = "Retry-Times";
/// <summary>
/// Request cache's id
/// </summary>
public static string RequestCacheId { get; } = "Request-Cache-Id";
/// <summary>
/// Http header name for keeping public key of receiver
/// </summary>
/// <remarks>
/// Put the header on response if receiver cannot decrypt data
/// </remarks>
public static string PublicKey { get; } = "Public-Key";
}
▋CustomHttpHeaderFactory.cs
public class CustomHeaderHttpClientHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
const int Zero = 0;
var isRetryTimesHeaderSet = request.Headers.TryGetValues(CustomHttpHeaderFactory.RetryTimes, out IEnumerable<string> retryTimesValues);
var isRequestCacheIdHeaderSet = request.Headers.TryGetValues(CustomHttpHeaderFactory.RequestCacheId, out IEnumerable<string> requestCacheIdValues);
// Add header: Retry-Times
if (!isRetryTimesHeaderSet || retryTimesValues.Count() > 1)
{
request.Headers.Remove(CustomHttpHeaderFactory.RetryTimes);
request.Headers.Add(CustomHttpHeaderFactory.RetryTimes, Zero.ToString());
}
// Add header: Request-Cache-Id
if (!isRequestCacheIdHeaderSet || requestCacheIdValues.Count() > 1)
{
request.Headers.Remove(CustomHttpHeaderFactory.RequestCacheId);
request.Headers.Add(CustomHttpHeaderFactory.RequestCacheId, CacheKeyFactory.GetKeyRequestCache());
}
// base.SendAsync calls the inner handler
var response = await base.SendAsync(request, cancellationToken);
return response;
}
}
▋EncryptHttpClientHandler.cs
public class EncryptHttpClientHandler : DelegatingHandler
{
private const int CacheRequestTimeout = 60; // Time(Seconds) to keeping the original request in MemoryCache
private readonly IKeyManager keyManager = null;
private readonly IMemoryCache memoryCache = null;
private readonly ILogger logger;
/// <summary>
/// Constructor
/// </summary>
public EncryptHttpClientHandler(
IKeyManager keyManager,
IMemoryCache memoryCache,
ILogger<EncryptHttpClientHandler> logger)
{
this.keyManager = keyManager;
this.memoryCache = memoryCache;
this.logger = logger;
}
/// <summary>
/// Send the request
/// </summary>
/// <param name="request">Request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>HttpResponseMessage</returns>
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
#region Get receiver-name and retry-times from Http Header
// Retry Times
request.Headers.TryGetValues(CustomHttpHeaderFactory.RetryTimes, out IEnumerable<string> retryTimesValues);
int retryTimes = 0;
int.TryParse(retryTimesValues.FirstOrDefault(), out retryTimes);
// RequestCacheId
request.Headers.TryGetValues(CustomHttpHeaderFactory.RequestCacheId, out IEnumerable<string> requestCacheIdValues);
var requestCacheId = requestCacheIdValues.FirstOrDefault();
#endregion
#region Encrypt the Http Request (Only in the first time)
if (retryTimes.Equals(0))
{
this.logger.LogDebug($"Start encrypting request...");
using (var rsa = new RsaService())
{
#region Get public key
var publicKey = await this.keyManager.GetPublicKeyAsync(KeyTypeEnum.RSA);
#endregion
#region Get original payload
// Load payload
string content = await request.Content.ReadAsStringAsync();
// Remove escapted character, eq. "\"xxxxx\"" => "xxxxx"
var jsonPayload = JsonConvert.DeserializeObject<string>(content);
#endregion
#region Save the original payload before encrypted
var cacheKey = requestCacheId;
this.memoryCache.Set(cacheKey, jsonPayload, DateTimeOffset.Now.AddSeconds(CacheRequestTimeout));
this.logger.LogDebug($"Successfully caching original request to memory cache: {cacheKey}.");
#endregion
#region Encrypt
// Encrypt
string encryptedPayload = await rsa.EncryptAsync(publicKey, jsonPayload);
// Replace the original content with the encrypted one
var newContent = new System.Net.Http.StringContent($"\"{encryptedPayload}\"", Encoding.UTF8, "application/json");
////newContent.Headers.ContentType.CharSet = string.Empty;
request.Content = newContent;
this.logger.LogDebug($"Successfully encrypting request.");
#endregion
}
}
#endregion
// base.SendAsync calls the inner handler
var response = await base.SendAsync(request, cancellationToken);
return response;
}
}
▋Polly: Retry policy
Now we are going to create a Polly retry policy which
can retry request on 422 response for Sender, and also
· Update
the correct Public Key to MemoryCache
· Encrypt
the original data with the correct Public Key
▋PollyRetryPolicyHandler.cs
public class PollyRetryPolicyHandler
{
private const int DefaultMaxRetryTimes = 1;
private readonly IKeyManager keyManager = null;
private readonly IMemoryCache memoryCache = null;
private readonly ILogger logger = null;
/// <summary>
/// Constructor
/// </summary>
/// <param name="keyManager">Key Manager</param>
/// <param name="memoryCache">MemoryCache</param>
/// <param name="logger"></param>
public PollyRetryPolicyHandler(
IKeyManager keyManager,
IMemoryCache memoryCache,
ILogger<PollyRetryPolicyHandler> logger)
{
this.keyManager = keyManager;
this.memoryCache = memoryCache;
this.logger = logger;
}
/// <summary>
/// Create Retry policy hander instance
/// </summary>
/// <returns>PolicyBuilder of HttpResponseMessage</returns>
public async Task<Polly.Retry.AsyncRetryPolicy<HttpResponseMessage>> CreateAsync(int maxRetryTimes = DefaultMaxRetryTimes)
{
var retryPolicy = Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode.Equals(HttpStatusCode.UnprocessableEntity))
.RetryAsync(maxRetryTimes, async (exception, retryCount) =>
{
this.logger.LogWarning($"The encrypted data was rejected, update public key and will retry {retryCount}/{maxRetryTimes}(times/total)...");
var request = exception.Result.RequestMessage;
var response = exception.Result;
// Public key
response.Headers.TryGetValues(CustomHttpHeaderFactory.PublicKey, out IEnumerable<string> publicKeyValues);
var correctPublicKey = publicKeyValues.FirstOrDefault();
// RequestICached
response.Headers.TryGetValues(CustomHttpHeaderFactory.RequestCacheId, out IEnumerable<string> requestCacheIdValues);
var requestCacheId = requestCacheIdValues.FirstOrDefault();
if (string.IsNullOrEmpty(correctPublicKey))
{
this.logger.LogWarning($"The response does not have required header: \"{CustomHttpHeaderFactory.PublicKey}\". Stop retrying the request!");
throw new OperationCanceledException();
}
else if (string.IsNullOrEmpty(requestCacheId))
{
this.logger.LogWarning($"The response does not have required header: \"{CustomHttpHeaderFactory.RequestCacheId}\" on response. Stop retrying the request!");
throw new OperationCanceledException();
}
else
{
#region Get the original request
var cacheKey = requestCacheId;
this.memoryCache.TryGetValue(cacheKey, out string jsonPayload);
if (string.IsNullOrEmpty(jsonPayload))
{
this.logger.LogWarning($"Lost the original request in MemoryCache (Key: {cacheKey}). Stop retrying the request!");
throw new OperationCanceledException();
}
#endregion
#region Encrypt the original request by the new public key
using (var rsa = new RsaService())
{
string encryptedPayload = await rsa.EncryptAsync(correctPublicKey, jsonPayload);
// Replace the original content with the encrypted one
var newContent = new System.Net.Http.StringContent($"\"{encryptedPayload}\"", Encoding.UTF8, "application/json");
request.Content = newContent;
this.logger.LogDebug($"Successfully encrypting request.");
}
#endregion
#region Retry times incremental
request.Headers.Remove(CustomHttpHeaderFactory.RetryTimes);
request.Headers.Add(CustomHttpHeaderFactory.RetryTimes, retryCount.ToString());
#endregion
#region Update the correct public key with KeyManager
var key = (await this.keyManager.GetKeyAsync(KeyTypeEnum.RSA));
key.PublicKey = correctPublicKey;
await this.keyManager.SaveKeyAsync(key);
this.logger.LogWarning($"Updated the correct public key. Now start retrying sending request.");
#endregion
}
});
return await Task.FromResult(retryPolicy);
}
}
▋Apply Handlers and
Retry policy to HttpClientFactory
The handlers and retry policy for
the Http request(s) can be applied to HttpClientFactory as following,
▋Startup.cs: ConfigureServices
services.AddTransient<CustomHeaderHttpClientHandler>();
services.AddTransient<EncryptHttpClientHandler>();
services.AddSingleton<PollyRetryPolicyHandler>();
services.AddHttpClient("MyHttpClient", x =>
{
x.BaseAddress = new Uri("https://localhost:5001");
x.Timeout = TimeSpan.FromMinutes(1);
})
.AddHttpMessageHandler<CustomHeaderHttpClientHandler>()
.AddHttpMessageHandler<EncryptHttpClientHandler>()
.AddPolicyHandler((serviceProvider, request) =>
{
var pollyHandler = serviceProvider.GetService<PollyRetryPolicyHandler>();
return pollyHandler.CreateAsync().Result;
});
▋Send a request with
encrypted content!
Now we can easily use the HttpClient
from injected HttpClientFactory to encrypt the content of a request!
var httpClient = this.httpClientFactory.CreateClient("MyHttpClient");
var response = await httpClient.PostAsJsonAsync("api/Demo/Receive", this.testData);
▌Implement Receiver
▋Resource Filter for
decrypting request’s content
The Receiver can use Resource Filters (IResourceFilter or IAsyncResourceFilter), to
decrypt the content of request like following,
[HttpPost]
[TypeFilter(typeof(DecryptRequestFilter))]
[Route("Receive")]
public async Task<IActionResult> ReceiveAsync([FromBody]string jsonStr)
{
var model = await Task.Run(() => JsonConvert.DeserializeObject<MyModel>(jsonStr));
// Now you can do something with the data...
}
That is because the Action Model’s
value (i.e. jsonStr) has been
already bind before Action Filters being
triggered.
Resource Filters will be
triggered before binding value to the Model, see below diagram from Microsoft Doc for
more details.
So here is the Resource Filter to
decrypt the content of request, including of response 422(Unprocessable Entity)
when cannot decrypt data.
▋DecryptRequestFilter.cs
public class DecryptRequestFilter : Attribute, IAsyncResourceFilter
{
private readonly IKeyManager keyManager = null;
private readonly ILogger logger = null;
/// <summary>
/// Constructor
/// </summary>
/// <param name="legalReceiver">The legal receiver</param>
/// <param name="keyManager">Key manager</param>
/// <param name="logger">Logger</param>
public DecryptRequestFilter(
IKeyManager keyManager,
ILogger<DecryptRequestFilter> logger)
{
this.keyManager = keyManager;
this.logger = logger;
}
/// <summary>
/// OnResourceExecutionAsync
/// </summary>
/// <param name="context">ResourceExecutingContext</param>
/// <param name="next">ResourceExecutionDelegate</param>
public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
{
this.logger.LogDebug($"Start decrpt process from {nameof(DecryptRequestFilter)}");
var isForwardRequestToApiAction = await this.OnBeforeActionAsync(context);
if (isForwardRequestToApiAction)
{
await next();
}
this.logger.LogDebug($"End decrpt process from {nameof(DecryptRequestFilter)}");
// HACK: remove this methods cus we cannot write Response after it's set by the action
////await this.OnAfterActionAsync(context);
}
/// <summary>
/// OnActionExecutionAsync
/// </summary>
/// <param name="context">ActionExecutingContext</param>
/// <param name="next">ActionExecutionDelegate</param>
private async Task<bool> OnBeforeActionAsync(ResourceExecutingContext context)
{
var request = context.HttpContext.Request;
var err = string.Empty;
bool isForwardRequestToApiAction = true;
// Get request's custom headers
var retryTimes = int.Parse(this.GetHeaderSingleValue(request.Headers, CustomHttpHeaderFactory.RetryTimes) ?? "0");
var requestCacheId = this.GetHeaderSingleValue(request.Headers, CustomHttpHeaderFactory.RequestCacheId);
// Validate the Receiver value from header
if (this.ValidateRequiredHeaders(requestCacheId, ref err))
{
var encryptedPayload = string.Empty;
#region Reading body from Http request
var initialBody = request.Body;
request.EnableBuffering(); // Use request.EnableRewind() instead before ASP.NET Core 3
using (var reader = new StreamReader(request.Body))
{
var content = await reader.ReadToEndAsync();
// Remove escapted character, eq. "\"xxxxx\"" => "xxxxx"
encryptedPayload = JsonConvert.DeserializeObject<string>(content);
}
#endregion
#region Decrypt the encrypted content
if (string.IsNullOrEmpty(encryptedPayload))
{
isForwardRequestToApiAction = false;
}
else
{
using (var rsa = new RsaService())
{
try
{
var privateKey = await this.keyManager.GetPrivateKeyAsync(KeyTypeEnum.RSA);
#region Decrypt and refactor to add escape charater for "\"
// Get decrypted string
var decryptedPayload = await rsa.DecryptAsync(privateKey, encryptedPayload);
// Add escape charater for \"
var escaptedDecryptedPayload = decryptedPayload.Replace("\"", "\\\"");
escaptedDecryptedPayload = $"\"{escaptedDecryptedPayload}\"";
#endregion
#region Convert the decrypted payload to byte array and bind to HttpRequest's body
byte[] byteContent = Encoding.UTF8.GetBytes(escaptedDecryptedPayload);
request.EnableBuffering(); // Use request.EnableRewind() instead before ASP.NET Core 3
request.Body.Position = 0;
using (var reader = new StreamReader(request.Body))
{
request.Body = new MemoryStream(byteContent);
request.Headers.Remove("content-type");
request.Headers.Add("content-type", "application/json");
}
#endregion
}
catch (Exception ex) when (ex is FormatException || ex is CryptographicException)
{
/*
* FormatException occurs when the private key is incorrect RSA key
* CryptographicException occuers when the data is incorrect encrypted
*/
var publicKey = await this.keyManager.GetPublicKeyAsync(KeyTypeEnum.RSA);
this.logger.LogWarning(ex, $"Cannot decrypt with private key!");
context.Result = new EmptyResult();
context.HttpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
context.HttpContext.Response.Headers.Add(CustomHttpHeaderFactory.RequestCacheId, requestCacheId);
context.HttpContext.Response.Headers.Add(CustomHttpHeaderFactory.PublicKey, publicKey);
isForwardRequestToApiAction = false;
}
catch (Exception ex)
{
this.logger.LogError(ex, ex.Message);
context.Result = new EmptyResult();
context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
isForwardRequestToApiAction = false;
}
}
}
this.logger.LogDebug($"{nameof(DecryptRequestFilter)}: end decrypting content from Http request.");
#endregion
}
if (!string.IsNullOrEmpty(err))
{
isForwardRequestToApiAction = false;
await context.HttpContext.Response.WriteAsync(err);
}
return isForwardRequestToApiAction;
}
}
Key points:
1. When the Sender
used an incorrect Public Key to encrypt, the decryption will throw CryptographicException. The
Resource Filter will overwrite the Http response to 422 response and then force
to response without going to API’s Action
2. While the request
is specified as “content-type: application/json” and the Model is string type
(JSON string), we have to deal with the escape character of JSON string.
l Remove escape character of the encrypted
cipher, eq. \"HJDS1234OFHREQELPKGGUIEHR\" to HJDS1234OFHREQELPKGGUIEHR
l After decryption, we get the JSON string with escape
character, like {\"Name\":\"JB\"}. We have to escape the “escape
character” and add " with escape
character on the first and last position before overwriting to memory stream of
Http request’s body. Eq. {\"Name\":\"JB\"}
to \"{\\""Name\\"":\\""JB\\""}\"
▌Put all together
Lets create 2 APIs to simulate
sending and receiving…
▋DemoController.cs
[Route("api/[controller]")]
[ApiController]
[AllowAnonymous]
public class DemoController : ControllerBase
{
private readonly IHttpClientFactory httpClientFactory = null;
private readonly ILogger logger = null;
private readonly string testData = null;
public DemoController(
IHttpClientFactory httpClientFactory,
ILogger<DemoController> logger)
{
this.httpClientFactory = httpClientFactory;
this.logger = logger;
var requestModel = new Bank()
{
Name = "NoMoney bank",
Merchants = new List<Merchant>()
{
new Merchant{ Name="Merchant1", Address = "@#$#$%^&" },
new Merchant{ Name="Merchant2", Address = ")^%$(*&#" }
}
};
this.testData = JsonConvert.SerializeObject(requestModel);
}
[HttpPost]
[Route("Send")]
public async Task<IActionResult> SendAsync()
{
var httpClient = this.httpClientFactory.CreateClient(HttpClientNameEnum.CipherHttpClient.ToString());
var response = await httpClient.PostAsJsonAsync("api/Demo/Receive", this.testData);
if (response.IsSuccessStatusCode)
{
return this.Ok();
}
else
{
return this.BadRequest();
}
}
[HttpPost]
[TypeFilter(typeof(DecryptRequestFilter))]
[Route("Receive")]
public async Task<IActionResult> ReceiveAsync([FromBody]string jsonStr)
{
// (Optional)Deserialize to an object, array or something...
var model = await Task.Run(() => JsonConvert.DeserializeObject<Bank>(jsonStr));
if (string.IsNullOrEmpty(jsonStr))
{
return this.BadRequest();
}
if (jsonStr.Equals(this.testData))
{
return this.Ok();
}
else
{
return this.StatusCode(StatusCodes.Status409Conflict);
}
}
}
By using HttpMessageHandler and
Resource Filter, the API’s code is clean :)
▋Demo
Here is the traffic for sending the
request with encrypted content.
▋See if retry policy works
To see if our retry policy works,
update the following code in DecryptRequestFilter,
#region Get public key
// var privateKey = await this.keyManager.GetPrivateKeyAsync(KeyTypeEnum.RSA);
// To test retry policy...
var privateKey = string.Empty;
if (retryTimes > 0)
privateKey = await this.keyManager.GetPrivateKeyAsync(KeyTypeEnum.RSA);
else
privateKey = (await this.keyManager.CreateDefaultAsymmetricKey(KeyTypeEnum.RSA, isIncludePrivateKey: true)).PrivateKey; // Set a incorrect private key to decrypt
#endregion
The first decryption will not use
the mapping/correct Private Key, that results in Sender will get 422 response
and trigger retry policy.
Lets see what will happens…
The first request will get 422
response with a correct Public Key in response’s header.
The retry request succeeds after
using the correct Public Key.
▌Reference